Merge branch 'develop' into T713-oauth-account-management

pull/243/head
Matt Baer 5 years ago
commit cf4f08b264
  1. 101
      CONTRIBUTING.md
  2. 4
      Dockerfile
  3. 4
      account.go
  4. 7
      activitypub.go
  5. 113
      admin.go
  6. 3
      app.go
  7. 61
      cmd/writefreely/config.go
  8. 50
      cmd/writefreely/db.go
  9. 39
      cmd/writefreely/keys.go
  10. 233
      cmd/writefreely/main.go
  11. 97
      cmd/writefreely/user.go
  12. 49
      cmd/writefreely/web.go
  13. 1
      config.ini.example
  14. 17
      config/config.go
  15. 6
      database-lib.go
  16. 2
      database.go
  17. 26
      db/create.go
  18. 3
      go.mod
  19. 10
      go.sum
  20. 3
      invites.go
  21. 48
      less/admin.less
  22. 54
      less/core.less
  23. 4
      less/new-core.less
  24. 2
      less/post-temp.less
  25. 18
      migrations/v4.go
  26. 49
      migrations/v5.go
  27. 30
      oauth.go
  28. 115
      oauth_gitlab.go
  29. 9
      pages/login.tmpl
  30. 25
      posts.go
  31. 16
      read.go
  32. 4
      routes.go
  33. 315
      semver.go
  34. 2
      templates/chorus-collection-post.tmpl
  35. 2
      templates/collection-post.tmpl
  36. 5
      templates/include/post-render.tmpl
  37. 4
      templates/post.tmpl
  38. 5
      templates/read.tmpl
  39. 146
      templates/user/admin.tmpl
  40. 154
      templates/user/admin/app-settings.tmpl
  41. 48
      templates/user/admin/app-updates.tmpl
  42. 105
      templates/user/admin/monitor.tmpl
  43. 5
      templates/user/admin/users.tmpl
  44. 3
      templates/user/admin/view-user.tmpl
  45. 2
      templates/user/articles.tmpl
  46. 2
      templates/user/collections.tmpl
  47. 2
      templates/user/export.tmpl
  48. 7
      templates/user/include/header.tmpl
  49. 6
      templates/user/settings.tmpl
  50. 131
      updates.go
  51. 82
      updates_test.go

@ -1,26 +1,99 @@
# Contributing to WriteFreely
Welcome! We're glad you're interested in contributing to the WriteFreely project.
Welcome! We're glad you're interested in contributing to WriteFreely.
To start, we'd suggest checking out [our Phabricator board](https://phabricator.write.as/tag/write_freely/) to see where the project is at and where it's going. You can also [join the WriteFreely forums](https://discuss.write.as/c/writefreely) to start talking about what you'd like to do or see.
For **questions**, **help**, **feature requests**, and **general discussion**, please use [our forum](https://discuss.write.as).
## Asking Questions
For **bug reports**, please [open a GitHub issue](https://github.com/writeas/writefreely/issues/new). See our guide on [submitting bug reports](https://writefreely.org/contribute#bugs).
The best place to get answers to your questions is on [our forums](https://discuss.write.as/c/writefreely). You can quickly log in using your GitHub account and ask the community about anything. We're also there to answer your questions and discuss potential changes or features.
## Getting Started
## Submitting Bugs
There are many ways to contribute to WriteFreely, from code to documentation, to translations, to help in the community!
Please use the [GitHub issue tracker](https://github.com/writeas/writefreely/issues/new) to report any bugs you encounter. We're very responsive there and try to keep open issues to a minimum, so you can help by:
See our [Contributing Guide](https://writefreely.org/contribute) on WriteFreely.org for ways to contribute without writing code. Otherwise, please read on.
* **Only reporting bugs in the issue tracker**
* Providing as much information as possible to replicate the issue, including server logs around the incident
* Including the `[app]` section of your configuration, if related
* Breaking issues into smaller pieces if they're larger or have many parts
## Working on WriteFreely
## Contributing code
First, you'll want to clone the WriteFreely repo, install development dependencies, and build the application from source. Learn how to do this in our [Development Setup](https://writefreely.org/docs/latest/developer/setup) guide.
We gladly welcome development help, regardless of coding experience. We can also use help [translating the app](https://poeditor.com/join/project/TIZ6HFRFdE) and documenting it!
### Starting development
**Before writing or submitting any code**, please sign our [contributor's agreement](https://phabricator.write.as/L1) so we can accept your contributions. It is substantially similar to the _Apache Individual Contributor License Agreement_. If you'd like to know about the rationale behind this requirement, you can [read more about that here](https://phabricator.write.as/w/writefreely/cla/).
Next, [join our forum](https://discuss.write.as) so you can discuss development with the team. Then take a look at [our roadmap on Phabricator](https://phabricator.write.as/tag/write_freely/) to see where the project is today and where it's headed.
Once you've done that, please feel free to [submit a pull request](https://github.com/writeas/writefreely/pulls) for any small improvements. For larger projects, please [join our development discussions](https://discuss.write.as/c/writefreely) or [get in touch](https://write.as/contact) so we can talk about what you'd like to work on.
When you find something you want to work on, start a new topic on the forum or jump into an existing discussion, if there is one. The team will respond and continue the conversation there.
Lastly, **before submitting any code**, please sign our [contributor's agreement](https://phabricator.write.as/L1) so we can accept your contributions. It is substantially similar to the _Apache Individual Contributor License Agreement_. If you'd like to know about the rationale behind this requirement, you can [read more about that here](https://phabricator.write.as/w/writefreely/cla/).
### Branching
All stable work lives on the `master` branch. We merge into it only when creating a release. Releases are tagged using semantic versioning.
While developing, we primarily work from the `develop` branch, creating _feature branches_ off of it for new features and fixes. When starting a new feature or fix, you should also create a new branch off of `develop`.
#### Branch naming
For fixes and modifications to existing behavior, branch names should follow a similar pattern to commit messages (see below), such as `fix-post-rendering` or `update-documentation`. You can optionally append a task number, e.g. `fix-post-rendering-T000`.
For new features, branches can be named after the new feature, e.g. `activitypub-mentions` or `import-zip`.
#### Pull request scope
The scope of work on each branch should be as small as possible -- one complete feature, one complete change, or one complete fix. This makes it easier for us to review and accept.
### Writing code
We value reliable, readable, and maintainable code over all else in our work. To help you write that kind of code, we offer a few guiding principles, as well as a few concrete guidelines.
#### Guiding principles
* Write code for other humans, not computers.
* The less complexity, the better. The more someone can understand code just by looking at it, the better.
* Functionality, readability, and maintainability over senseless elegance.
* Only abstract when necessary.
* Keep an eye to the future, but don't pre-optimize at the expense of today's simplicity.
#### Code guidelines
* Format all Go code with `go fmt` before committing (**important!**)
* Follow whitespace conventions established within the project (tabs vs. spaces)
* Add comments to exported Go functions and variables
* Follow Go naming conventions, like using [`mixedCaps`](https://golang.org/doc/effective_go.html#mixed-caps)
* Avoid new dependencies unless absolutely necessary
### Commit messages
We highly value commit messages that follow established form within the project. Generally speaking, we follow the practices [outlined](https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_commit_guidelines) in the Pro Git Book. A good commit message will look like the following:
* **Line 1**: A short summary written in the present imperative tense. For example:
* ✔ **Good**: "Fix post rendering bug"
* ❌ No: ~~"Fixes post rendering bug"~~
* ❌ No: ~~"Fixing post rendering bug"~~
* ❌ No: ~~"Fixed post rendering bug"~~
* ❌ No: ~~"Post rendering bug is fixed now"~~
* **Line 2**: _[left blank]_
* **Line 3**: An added description of what changed, any rationale, etc. -- if necessary
* **Last line**: A mention of any applicable task or issue
* For Phabricator tasks: `Ref T000` or `Closes T000`
* For GitHub issues: `Ref #000` or `Fixes #000`
#### Good examples
When in doubt, look to our existing git history for examples of good commit messages. Here are a few:
* [Rename Suspend status to Silence](https://github.com/writeas/writefreely/commit/7e014ca65958750ab703e317b1ce8cfc4aad2d6e)
* [Show 404 when remote user not found](https://github.com/writeas/writefreely/commit/867eb53b3596bd7b3f2be3c53a3faf857f4cd36d)
* [Fix post deletion on Pleroma](https://github.com/writeas/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,5 +1,5 @@
# Build image
FROM golang:1.12-alpine as build
FROM golang:1.13-alpine as build
RUN apk add --update nodejs nodejs-npm make g++ git sqlite-dev
RUN npm install -g less less-plugin-clean-css
@ -22,7 +22,7 @@ RUN mkdir /stage && \
/stage
# Final image
FROM alpine:3.8
FROM alpine:3.11
RUN apk add --no-cache openssl ca-certificates
COPY --from=build --chown=daemon:daemon /stage /go

@ -308,6 +308,8 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
LoginUsername string
OauthSlack bool
OauthWriteAs bool
OauthGitlab bool
GitlabDisplayName string
}{
pageForReq(app, r),
r.FormValue("to"),
@ -316,6 +318,8 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
getTempInfo(app, "login-user", r, w),
app.Config().SlackOauth.ClientID != "",
app.Config().WriteAsOauth.ClientID != "",
app.Config().GitlabOauth.ClientID != "",
config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName),
}
if earlyError != "" {

@ -607,7 +607,12 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
na.CC = append(na.CC, f)
}
err = makeActivityPost(app.cfg.App.Host, actor, si, activitystreams.NewDeleteActivity(na))
da := activitystreams.NewDeleteActivity(na)
// Make the ID unique to ensure it works in Pleroma
// See: https://git.pleroma.social/pleroma/pleroma/issues/1481
da.ID += "#Delete"
err = makeActivityPost(app.cfg.App.Host, actor, si, da)
if err != nil {
log.Error("Couldn't delete post! %v", err)
}

@ -1,5 +1,5 @@
/*
* Copyright © 2018-2019 A Bunch Tell LLC.
* Copyright © 2018-2020 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -90,6 +90,18 @@ type instanceContent struct {
Updated time.Time
}
type AdminPage struct {
UpdateAvailable bool
}
func NewAdminPage(app *App) *AdminPage {
ap := &AdminPage{}
if app.updates != nil {
ap.UpdateAvailable = app.updates.AreAvailableNoCheck()
}
return ap
}
func (c instanceContent) UpdatedFriendly() string {
/*
// TODO: accept a locale in this method and use that for the format
@ -100,15 +112,46 @@ func (c instanceContent) UpdatedFriendly() string {
}
func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
*AdminPage
Message string
UsersCount, CollectionsCount, PostsCount int64
}{
UserPage: NewUserPage(app, r, u, "Admin", nil),
AdminPage: NewAdminPage(app),
Message: r.FormValue("m"),
}
// Get user stats
p.UsersCount = app.db.GetAllUsersCount()
var err error
p.CollectionsCount, err = app.db.GetTotalCollections()
if err != nil {
return err
}
p.PostsCount, err = app.db.GetTotalPosts()
if err != nil {
return err
}
showUserPage(w, "admin", p)
return nil
}
func handleViewAdminMonitor(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
updateAppStats()
p := struct {
*UserPage
*AdminPage
SysStatus systemStatus
Config config.AppCfg
Message, ConfigMessage string
}{
UserPage: NewUserPage(app, r, u, "Admin", nil),
AdminPage: NewAdminPage(app),
SysStatus: sysStatus,
Config: app.cfg.App,
@ -116,13 +159,34 @@ func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Reque
ConfigMessage: r.FormValue("cm"),
}
showUserPage(w, "admin", p)
showUserPage(w, "monitor", p)
return nil
}
func handleViewAdminSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
*AdminPage
Config config.AppCfg
Message, ConfigMessage string
}{
UserPage: NewUserPage(app, r, u, "Admin", nil),
AdminPage: NewAdminPage(app),
Config: app.cfg.App,
Message: r.FormValue("m"),
ConfigMessage: r.FormValue("cm"),
}
showUserPage(w, "app-settings", p)
return nil
}
func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
*AdminPage
Config config.AppCfg
Message string
@ -132,6 +196,7 @@ func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Requ
TotalPages []int
}{
UserPage: NewUserPage(app, r, u, "Users", nil),
AdminPage: NewAdminPage(app),
Config: app.cfg.App,
Message: r.FormValue("m"),
}
@ -169,6 +234,7 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
p := struct {
*UserPage
*AdminPage
Config config.AppCfg
Message string
@ -179,6 +245,7 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
TotalPosts int64
ClearEmail string
}{
AdminPage: NewAdminPage(app),
Config: app.cfg.App,
Message: r.FormValue("m"),
Colls: []inspectedCollection{},
@ -304,12 +371,14 @@ func handleAdminResetUserPass(app *App, u *User, w http.ResponseWriter, r *http.
func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
*AdminPage
Config config.AppCfg
Message string
Pages []*instanceContent
}{
UserPage: NewUserPage(app, r, u, "Pages", nil),
AdminPage: NewAdminPage(app),
Config: app.cfg.App,
Message: r.FormValue("m"),
}
@ -368,12 +437,14 @@ func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Reque
p := struct {
*UserPage
*AdminPage
Config config.AppCfg
Message string
Banner *instanceContent
Content *instanceContent
}{
AdminPage: NewAdminPage(app),
Config: app.cfg.App,
Message: r.FormValue("m"),
}
@ -475,7 +546,7 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt
if err != nil {
m = "?cm=" + err.Error()
}
return impart.HTTPError{http.StatusFound, "/admin" + m + "#config"}
return impart.HTTPError{http.StatusFound, "/admin/settings" + m + "#config"}
}
func updateAppStats() {
@ -528,3 +599,39 @@ func adminResetPassword(app *App, u *User, newPass string) error {
}
return nil
}
func handleViewAdminUpdates(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
check := r.URL.Query().Get("check")
if check == "now" && app.cfg.App.UpdateChecks {
app.updates.CheckNow()
}
p := struct {
*UserPage
*AdminPage
CurReleaseNotesURL string
LastChecked string
LastChecked8601 string
LatestVersion string
LatestReleaseURL string
LatestReleaseNotesURL string
CheckFailed bool
}{
UserPage: NewUserPage(app, r, u, "Updates", nil),
AdminPage: NewAdminPage(app),
}
p.CurReleaseNotesURL = wfReleaseNotesURL(p.Version)
if app.cfg.App.UpdateChecks {
p.LastChecked = app.updates.lastCheck.Format("January 2, 2006, 3:04 PM")
p.LastChecked8601 = app.updates.lastCheck.Format("2006-01-02T15:04:05Z")
p.LatestVersion = app.updates.LatestVersion()
p.LatestReleaseURL = app.updates.ReleaseURL()
p.LatestReleaseNotesURL = app.updates.ReleaseNotesURL()
p.UpdateAvailable = app.updates.AreAvailable()
p.CheckFailed = app.updates.checkError != nil
}
showUserPage(w, "app-updates", p)
return nil
}

@ -72,6 +72,7 @@ type App struct {
keys *key.Keychain
sessionStore sessions.Store
formDecoder *schema.Decoder
updates *updatesCache
timeline *localTimeline
}
@ -371,6 +372,8 @@ func Initialize(apper Apper, debug bool) (*App, error) {
if err != nil {
return nil, fmt.Errorf("init keys: %s", err)
}
apper.App().InitUpdates()
apper.App().InitSession()
apper.App().InitDecoder()

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

@ -0,0 +1,97 @@
/*
* 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 main
import (
"fmt"
"github.com/writeas/writefreely"
"github.com/urfave/cli/v2"
)
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,49 @@
/*
* 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 main
import (
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely"
"github.com/gorilla/mux"
"github.com/urfave/cli/v2"
)
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
}

@ -23,4 +23,5 @@ max_blogs = 1
federation = true
public_stats = true
private = false
update_checks = true

@ -12,8 +12,9 @@
package config
import (
"gopkg.in/ini.v1"
"strings"
"gopkg.in/ini.v1"
)
const (
@ -68,6 +69,15 @@ type (
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
GitlabOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
Host string `ini:"host"`
DisplayName string `ini:"display_name"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
SlackOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
@ -93,6 +103,7 @@ type (
// Site functionality
Chorus bool `ini:"chorus"`
Forest bool `ini:"forest"` // The admin cares about the forest, not the trees. Hide unnecessary technical info.
DisableDrafts bool `ini:"disable_drafts"`
// Users
@ -114,6 +125,9 @@ type (
// Defaults
DefaultVisibility string `ini:"default_visibility"`
// Check for Updates
UpdateChecks bool `ini:"update_checks"`
}
// Config holds the complete configuration for running a writefreely instance
@ -123,6 +137,7 @@ type (
App AppCfg `ini:"app"`
SlackOauth SlackOauthCfg `ini:"oauth.slack"`
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"`
}
)

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

@ -2515,7 +2515,7 @@ func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) {
func (db *datastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUser int64) (string, error) {
state := store.Generate62RandomString(24)
attachUserVal := sql.NullInt64{Valid: attachUser > 0, Int64: attachUser}
_, err := db.ExecContext(ctx, "INSERT INTO oauth_client_states (state, provider, client_id, used, created_at, attach_user_id) VALUES (?, ?, ?, FALSE, NOW(), ?)", state, provider, clientID, attachUserVal)
_, err := db.ExecContext(ctx, "INSERT INTO oauth_client_states (state, provider, client_id, used, created_at, attach_user_id) VALUES (?, ?, ?, FALSE, "+db.now()+", ?)", state, provider, clientID, attachUserVal)
if err != nil {
return "", fmt.Errorf("unable to record oauth client state: %w", err)
}

@ -1,3 +1,13 @@
/*
* Copyright © 2019-2020 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package db
import (
@ -139,6 +149,15 @@ func (c *Column) SetDefault(value string) *Column {
return c
}
func (c *Column) SetDefaultCurrentTimestamp() *Column {
def := "NOW()"
if c.Dialect == DialectSQLite {
def = "CURRENT_TIMESTAMP"
}
c.Default = OptionalString{Set: true, Value: def}
return c
}
func (c *Column) SetType(t ColumnType) *Column {
c.Type = t
return c
@ -168,7 +187,11 @@ func (c *Column) String() (string, error) {
if c.Default.Set {
str.WriteString(" DEFAULT ")
str.WriteString(c.Default.Value)
val := c.Default.Value
if val == "" {
val = "''"
}
str.WriteString(val)
}
if c.PrimaryKey {
@ -241,4 +264,3 @@ func (b *CreateTableSqlBuilder) ToSQL() (string, error) {
return str.String(), nil
}

@ -1,7 +1,6 @@
module github.com/writeas/writefreely
require (
github.com/BurntSushi/toml v0.3.1 // indirect
github.com/alecthomas/gometalinter v3.0.0+incompatible // indirect
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 // indirect
@ -38,6 +37,7 @@ require (
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
github.com/stretchr/testify v1.3.0
github.com/urfave/cli/v2 v2.1.1
github.com/writeas/activity v0.1.2
github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89
github.com/writeas/go-strip-markdown v2.0.1+incompatible
@ -57,7 +57,6 @@ require (
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
)

@ -22,6 +22,8 @@ github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -112,6 +114,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY=
@ -124,12 +128,10 @@ github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9 h1:vY5WqiEon0ZSTGM3ayVVi+twaHKHDFUVloaQ/wug9/c=
github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9/go.mod h1:q+QjxYvZ+fpjMXqs+XEriussHjSYqeXVnAdSV1tkMYk=
github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY=
github.com/writeas/activity v0.1.2/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0=
github.com/writeas/activityserve v0.0.0-20191008122325-5fc3b48e70c5 h1:nG84xWpxBM8YU/FJchezJqg7yZH8ImSRow6NoYtbSII=
github.com/writeas/activityserve v0.0.0-20191008122325-5fc3b48e70c5/go.mod h1:Kz62mzYsCnrFTSTSFLXFj3fGYBQOntmBWTDDq57b46A=
github.com/writeas/activityserve v0.0.0-20191011072627-3a81f7784d5b h1:rd2wX/bTqD55hxtBjAhwLcUgaQE36c70KX3NzpDAwVI=
github.com/writeas/activityserve v0.0.0-20191011072627-3a81f7784d5b/go.mod h1:Kz62mzYsCnrFTSTSFLXFj3fGYBQOntmBWTDDq57b46A=
github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89 h1:NJhzq9aTccL3SSSZMrcnYhkD6sObdY9otNZ1X6/ZKNE=
github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89/go.mod h1:Kz62mzYsCnrFTSTSFLXFj3fGYBQOntmBWTDDq57b46A=
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=

@ -170,6 +170,9 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
p.Error = "This invite link has expired."
}
// Tell search engines not to index invite links
w.Header().Set("X-Robots-Tag", "noindex")
// Get error messages
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {

@ -13,14 +13,20 @@ nav#admin {
display: block;
margin: 0.5em 0;
a {
color: @primary;
&:first-child {
margin-left: 0;
}
.rounded(.25em);
border: 0;
&.selected {
background: #dedede;
font-weight: bold;
.blip {
color: black;
}
}
}
.blip {
font-weight: bold;
}
}
.pager {
display: flex;
@ -42,3 +48,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;
}
}

@ -524,12 +524,12 @@ pre, body#post article, #post .alert, #subpage .alert, body#collection article,
margin-bottom: 1em;
p {
text-align: left;
line-height: 1.4;
line-height: 1.5;
}
}
textarea, pre, body#post article, body#collection article p {
&.norm, &.sans, &.wrap {
line-height: 1.4em;
line-height: 1.5;
white-space: pre-wrap; /* CSS 3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
@ -639,6 +639,23 @@ table.classy {
}
}
article table {
border-spacing: 0;
border-collapse: collapse;
width: 100%;
th {
border-width: 1px 1px 2px 1px;
border-style: solid;
border-color: #ccc;
}
td {
border-width: 0 1px 1px 1px;
border-style: solid;
border-color: #ccc;
padding: .25rem .5rem;
}
}
body#collection article, body#subpage article {
padding-top: 0;
padding-bottom: 0;
@ -794,9 +811,6 @@ input {
&.snug {
max-width: 40em;
}
&.regular {
font-size: 1em;
}
.app {
+ .app {
margin-top: 1.5em;
@ -813,7 +827,7 @@ input {
font-weight: normal;
}
p {
line-height: 1.4;
line-height: 1.5;
}
li {
margin: 0.3em 0;
@ -868,20 +882,6 @@ input {
text-align: center;
}
}
div.features {
margin-top: 1.5em;
text-align: center;
font-size: 0.86em;
ul {
text-align: left;
max-width: 26em;
margin-left: auto !important;
margin-right: auto !important;
li.soon, span.soon {
color: lighten(#111, 40%);
}
}
}
div.blurbs {
>h2 {
text-align: center;
@ -1007,7 +1007,7 @@ footer.contain-me {
}
li {
line-height: 1.4;
line-height: 1.5;
.item-desc, .prog-lang {
font-size: 0.6em;
@ -1345,6 +1345,16 @@ div.row {
}
}
.check, .blip {
font-size: 1.125em;
color: #71D571;
}
.ex.failure {
font-weight: bold;
color: @dangerCol;
}
@media all and (max-width: 450px) {
body#post {
header {
@ -1411,7 +1421,7 @@ div.row {
}
@media all and (max-width: 600px) {
div.row {
div.row:not(.admin-actions) {
flex-direction: column;
}
.half {

@ -113,7 +113,7 @@ textarea {
ul {
margin: 0;
padding: 0 0 0 1em;
line-height: 1.4;
line-height: 1.5;
&.collections, &.posts, &.integrations {
list-style: none;
@ -206,7 +206,7 @@ code, textarea#embed {
font-weight: normal;
}
p {
line-height: 1.4;
line-height: 1.5;
}
li {
margin: 0.3em 0;

@ -58,7 +58,7 @@ body#post article, pre, .hljs {
}
}
.article-p() {
line-height: 1.4em;
line-height: 1.5;
white-space: pre-wrap; /* CSS 3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */

@ -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 migrations
import (
@ -15,21 +25,19 @@ func oauth(db *datastore) error {
return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
createTableUsersOauth, err := dialect.
Table("oauth_users").
SetIfNotExists(true).
SetIfNotExists(false).
Column(dialect.Column("user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)).
Column(dialect.Column("remote_user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)).
UniqueConstraint("user_id").
UniqueConstraint("remote_user_id").
ToSQL()
if err != nil {
return err
}
createTableOauthClientState, err := dialect.
Table("oauth_client_states").
SetIfNotExists(true).
SetIfNotExists(false).
Column(dialect.Column("state", wf_db.ColumnTypeVarChar, wf_db.OptionalInt{Set: true, Value: 255})).
Column(dialect.Column("used", wf_db.ColumnTypeBool, wf_db.UnsetSize)).
Column(dialect.Column("created_at", wf_db.ColumnTypeDateTime, wf_db.UnsetSize).SetDefault("NOW()")).
Column(dialect.Column("created_at", wf_db.ColumnTypeDateTime, wf_db.UnsetSize).SetDefaultCurrentTimestamp()).
UniqueConstraint("state").
ToSQL()
if err != nil {

@ -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 migrations
import (
@ -20,39 +30,50 @@ func oauthSlack(db *datastore) error {
Column(
"provider",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 24,})).
wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")),
dialect.
AlterTable("oauth_client_states").
AddColumn(dialect.
Column(
"client_id",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 128,})),
wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")),
dialect.
AlterTable("oauth_users").
ChangeColumn("remote_user_id",
dialect.
Column(
"remote_user_id",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 128,})).
AddColumn(dialect.
Column(
"provider",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 24,})).
wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")),
dialect.
AlterTable("oauth_users").
AddColumn(dialect.
Column(
"client_id",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 128,})).
wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")),
dialect.
AlterTable("oauth_users").
AddColumn(dialect.
Column(
"access_token",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 512,})),
dialect.DropIndex("remote_user_id", "oauth_users"),
dialect.DropIndex("user_id", "oauth_users"),
dialect.CreateUniqueIndex("oauth_users", "oauth_users", "user_id", "provider", "client_id"),
wf_db.OptionalInt{Set: true, Value: 512}).SetDefault("")),
dialect.CreateUniqueIndex("oauth_users_uk", "oauth_users", "user_id", "provider", "client_id"),
}
if dialect != wf_db.DialectSQLite {
// This updates the length of the `remote_user_id` column. It isn't needed for SQLite databases.
builders = append(builders, dialect.
AlterTable("oauth_users").
ChangeColumn("remote_user_id",
dialect.
Column(
"remote_user_id",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 128})))
}
for _, builder := range builders {
query, err := builder.ToSQL()
if err != nil {

@ -162,7 +162,7 @@ func configureWriteAsOauth(parentHandler *Handler, r *mux.Router, app *App) {
callbackLocation: app.Config().App.Host + "/oauth/callback/write.as",
httpClient: config.DefaultHTTPClient(),
}
callbackLocation = app.Config().SlackOauth.CallbackProxy
callbackLocation = app.Config().WriteAsOauth.CallbackProxy
}
oauthClient := writeAsOauthClient{
@ -178,6 +178,34 @@ 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 configureOauthRoutes(parentHandler *Handler, r *mux.Router, app *App, oauthClient oauthClient, callbackProxy *callbackProxyClient) {
handler := &oauthHandler{
Config: app.Config(),

@ -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", "writefreely")
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", "writefreely")
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
}

@ -32,6 +32,10 @@ hr.short {
box-sizing: border-box;
font-size: 17px;
}
#gitlab-login {
box-sizing: border-box;
font-size: 17px;
}
</style>
{{end}}
{{define "content"}}
@ -42,7 +46,7 @@ hr.short {
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
{{ if or .OauthSlack .OauthWriteAs }}
{{ if or .OauthSlack .OauthWriteAs .OauthGitlab }}
<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>
@ -50,6 +54,9 @@ hr.short {
{{ if .OauthWriteAs }}
<a class="btn cta loginbtn" id="writeas-login" href="/oauth/write.as">Sign in with <strong>Write.as</strong></a>
{{ end }}
{{ if .OauthGitlab }}
<a class="btn cta loginbtn" id="gitlab-login" href="/oauth/gitlab">Sign in with <strong>{{.GitlabDisplayName}}</strong></a>
{{ end }}
</div>
<div class="or">

@ -16,6 +16,7 @@ import (
"fmt"
"html/template"
"net/http"
"net/url"
"regexp"
"strings"
"time"
@ -62,6 +63,7 @@ type (
Description string
Author string
Views int64
Images []string
IsPlainText bool
IsCode bool
IsLinkable bool
@ -381,6 +383,7 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
}
if !isRaw {
post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "", app.cfg))
post.Images = extractImages(post.Content)
}
}
@ -1541,22 +1544,32 @@ func (rp *RawPost) Created8601() string {
return rp.Created.Format("2006-01-02T15:04:05Z")
}
var imageURLRegex = regexp.MustCompile(`(?i)^https?:\/\/[^ ]*\.(gif|png|jpg|jpeg|image)$`)
var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|image)$`)
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{}
for i := range matches {
u := matches[i].Text
if !imageURLRegex.MatchString(u) {
uRaw := matches[i].Text
// 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
}
urls[u] = true
urls[uRaw] = true
}
resURLs := make([]string, 0)
for k := range urls {
resURLs = append(resURLs, k)
}
p.Images = resURLs
return resURLs
}

@ -1,5 +1,5 @@
/*
* Copyright © 2018-2019 A Bunch Tell LLC.
* Copyright © 2018-2020 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -33,6 +33,8 @@ const (
tlAPIPageLimit = 10
tlMaxAuthorPosts = 5
tlPostsPerPage = 16
tlMaxPostCache = 250
tlCacheDur = 10 * time.Minute
)
type localTimeline struct {
@ -60,19 +62,25 @@ type readPublication struct {
func initLocalTimeline(app *App) {
app.timeline = &localTimeline{
postsPerPage: tlPostsPerPage,
m: memo.New(app.FetchPublicPosts, 10*time.Minute),
m: memo.New(app.FetchPublicPosts, tlCacheDur),
}
}
// satisfies memo.Func
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
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
LEFT JOIN posts p ON p.collection_id = c.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
ORDER BY p.created DESC`)
WHERE c.privacy = 1 AND (p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) AND u.status = 0
ORDER BY p.created DESC
` + limit)
if err != nil {
log.Error("Failed selecting from posts: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts." + err.Error()}

@ -75,6 +75,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
configureSlackOauth(handler, write, apper.App())
configureWriteAsOauth(handler, write, apper.App())
configureGitlabOauth(handler, write, apper.App())
// Set up dyamic page handlers
// Handle auth
@ -153,6 +154,8 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
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/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET")
write.HandleFunc("/admin/user/{username}/status", handler.Admin(handleAdminToggleUserStatus)).Methods("POST")
@ -161,6 +164,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET")
write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).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
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))

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

@ -58,7 +58,7 @@ body#post header {
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
<article id="post-body" class="{{.Font}} h-entry">{{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}{{if .Title.String}}<h2 id="title" class="p-name{{if $.Collection.Format.ShowDates}} dated{{end}}">{{.FormattedDisplayTitle}}</h2>{{end}}{{if $.Collection.Format.ShowDates}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{.DisplayDate}}</time>{{end}}<div class="e-content">{{.HTMLContent}}</div></article>
<article id="post-body" class="{{.Font}} h-entry">{{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}{{if .Title.String}}<h2 id="title" class="p-name{{if $.Collection.Format.ShowDates}} dated{{end}}">{{.FormattedDisplayTitle}}</h2>{{end}}{{if and $.Collection.Format.ShowDates (not .IsPinned)}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{.DisplayDate}}</time>{{end}}<div class="e-content">{{.HTMLContent}}</div></article>
{{ if .Collection.ShowFooterBranding }}
<footer dir="ltr">

@ -62,7 +62,7 @@
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
<article id="post-body" class="{{.Font}} h-entry {{if not .IsFound}}error-page{{end}}">{{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}{{if .Title.String}}<h2 id="title" class="p-name{{if $.Collection.Format.ShowDates}} dated{{end}}">{{.FormattedDisplayTitle}}</h2>{{end}}{{if $.Collection.Format.ShowDates}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{.DisplayDate}}</time>{{end}}<div class="e-content">{{.HTMLContent}}</div></article>
<article id="post-body" class="{{.Font}} h-entry {{if not .IsFound}}error-page{{end}}">{{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}{{if .Title.String}}<h2 id="title" class="p-name{{if $.Collection.Format.ShowDates}} dated{{end}}">{{.FormattedDisplayTitle}}</h2>{{end}}{{if and $.Collection.Format.ShowDates (not .IsPinned)}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{.DisplayDate}}</time>{{end}}<div class="e-content">{{.HTMLContent}}</div></article>
{{ if .Collection.ShowFooterBranding }}
<footer dir="ltr"><hr><nav><p style="font-size: 0.9em">{{localhtml "published with write.as" .Language.String}}</p></nav></footer>

@ -20,7 +20,10 @@
"hh" : "cpp",
"hxx" : "cpp",
"cxx" : "cpp",
"sh" : "bash"
"sh" : "bash",
"js" : "javascript",
"jsx" : "javascript",
"html" : "xml"
};
// Given a set of nodes, run highlighting on them

@ -23,13 +23,13 @@
<meta name="twitter:description" content="{{.Description}}">
{{if gt .Views 1}}<meta name="twitter:label1" value="Views">
<meta name="twitter:data1" value="{{largeNumFmt .Views}}">{{end}}
<meta name="twitter:image" content="{{.Host}}/img/wf-sq.png">
{{if gt (len .Images) 0}}<meta name="twitter:image" content="{{index .Images 0}}">{{else}}<meta name="twitter:image" content="{{.Host}}/img/wf-sq.png">{{end}}
<meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.GenTitle}}{{end}}" />
<meta property="og:site_name" content="{{.SiteName}}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{.Host}}/{{if .SingleUser}}d/{{end}}{{.ID}}" />
<meta property="og:description" content="{{.Description}}" />
<meta property="og:image" content="{{.Host}}/img/wf-sq.png">
{{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Host}}/img/wf-sq.png">{{end}}
{{if .Author}}<meta property="article:author" content="https://{{.Author}}" />{{end}}
<!-- Add highlighting logic -->
{{template "highlighting" .}}

@ -75,11 +75,14 @@
body#collection header nav.tabs a:first-child {
margin-left: 1em;
}
body#collection article {
max-width: 40em !important;
}
</style>
{{end}}
{{define "body-attrs"}}id="collection"{{end}}
{{define "content"}}
<div class="content-container snug" style="max-width: 40rem;">
<div class="content-container snug">
<h1>{{.ContentTitle}}</h1>
<p{{if .SelTopic}} style="text-align:center"{{end}}>{{if .SelTopic}}#{{.SelTopic}} posts{{else}}{{.Content}}{{end}}</p>
</div>

@ -35,6 +35,14 @@ form dt {
p.docs {
font-size: 0.86em;
}
.stats {
font-size: 1.2em;
margin: 1em 0;
}
.num {
font-weight: bold;
font-size: 1.5em;
}
</style>
<div class="content-container snug">
@ -42,142 +50,12 @@ p.docs {
{{if .Message}}<p>{{.Message}}</p>{{end}}
<h2>On this page</h2>
<ul class="pagenav">
<li><a href="#config">Configuration</a></li>
<li><a href="#monitor">Application monitor</a></li>
</ul>
<h2>Resources</h2>
<ul class="pagenav">
<li><a href="https://writefreely.org/docs/{{.OfficialVersion}}/admin">Admin Guide</a></li>
</ul>
<hr />
<h2><a name="config"></a>App Configuration</h2>
<p class="docs">Read more in the <a href="https://writefreely.org/docs/{{.OfficialVersion}}/admin/config">configuration docs</a>.</p>
{{if .ConfigMessage}}<p class="success" style="text-align: center">{{.ConfigMessage}}</p>{{end}}
<form action="/admin/update/config" method="post">
<div class="ui attached table segment">
<dl class="dl-horizontal admin-dl-horizontal">
<dt{{if .Config.SingleUser}} class="invisible"{{end}}>Site Name</dt>
<dd{{if .Config.SingleUser}} class="invisible"{{end}}><input type="text" name="site_name" id="site_name" class="inline" value="{{.Config.SiteName}}" style="width: 14em;" /></dd>
<dt{{if .Config.SingleUser}} class="invisible"{{end}}>Site Description</dt>
<dd{{if .Config.SingleUser}} class="invisible"{{end}}><input type="text" name="site_desc" id="site_desc" class="inline" value="{{.Config.SiteDesc}}" style="width: 14em;" /></dd>
<dt>Host</dt>
<dd>{{.Config.Host}}</dd>
<dt>User Mode</dt>
<dd>{{if .Config.SingleUser}}Single user{{else}}Multiple users{{end}}</dd>
<dt{{if .Config.SingleUser}} class="invisible"{{end}}>Landing Page</dt>
<dd{{if .Config.SingleUser}} class="invisible"{{end}}><input type="text" name="landing" id="landing" class="inline" value="{{.Config.Landing}}" style="width: 14em;" /></dd>
<dt{{if .Config.SingleUser}} class="invisible"{{end}}><label for="open_registration">Open Registrations</label></dt>
<dd{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="open_registration" id="open_registration" {{if .Config.OpenRegistration}}checked="checked"{{end}} /></dd>
<dt><label for="min_username_len">Minimum Username Length</label></dt>
<dd><input type="number" name="min_username_len" id="min_username_len" class="inline" min="1" max="100" value="{{.Config.MinUsernameLen}}" /></dd>
<dt{{if .Config.SingleUser}} class="invisible"{{end}}><label for="max_blogs">Maximum Blogs per User</label></dt>
<dd{{if .Config.SingleUser}} class="invisible"{{end}}><input type="number" name="max_blogs" id="max_blogs" class="inline" min="1" value="{{.Config.MaxBlogs}}" /></dd>
<dt><label for="federation">Federation</label></dt>
<dd><input type="checkbox" name="federation" id="federation" {{if .Config.Federation}}checked="checked"{{end}} /></dd>
<dt><label for="public_stats">Public Stats</label></dt>
<dd><input type="checkbox" name="public_stats" id="public_stats" {{if .Config.PublicStats}}checked="checked"{{end}} /></dd>
<dt><label for="private">Private Instance</label></dt>
<dd><input type="checkbox" name="private" id="private" {{if .Config.Private}}checked="checked"{{end}} /></dd>
<dt{{if .Config.SingleUser}} class="invisible"{{end}}><label for="local_timeline">Local Timeline</label></dt>
<dd{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="local_timeline" id="local_timeline" {{if .Config.LocalTimeline}}checked="checked"{{end}} /></dd>
<dt{{if .Config.SingleUser}} class="invisible"{{end}}><label for="user_invites">Allow sending invitations by</label></dt>
<dd{{if .Config.SingleUser}} class="invisible"{{end}}>
<select name="user_invites" id="user_invites">
<option value="none" {{if eq .Config.UserInvites ""}}selected="selected"{{end}}>No one</option>
<option value="user" {{if eq .Config.UserInvites "user"}}selected="selected"{{end}}>Users</option>
<option value="admin" {{if eq .Config.UserInvites "admin"}}selected="selected"{{end}}>Admins</option>
</select>
</dd>
<dt{{if .Config.SingleUser}} class="invisible"{{end}}><label for="default_visibility">Default blog visibility</label></dt>
<dd{{if .Config.SingleUser}} class="invisible"{{end}}>
<select name="default_visibility" id="default_visibility">
<option value="unlisted" {{if eq .Config.DefaultVisibility "unlisted"}}selected="selected"{{end}}>Unlisted</option>
<option value="public" {{if eq .Config.DefaultVisibility "public"}}selected="selected"{{end}}>Public</option>
<option value="private" {{if eq .Config.DefaultVisibility "private"}}selected="selected"{{end}}>Private</option>
</select>
</dd>
</dl>
<input type="submit" value="Save Configuration" />
<div class="row stats">
<div><span class="num">{{largeNumFmt .UsersCount}}</span> {{pluralize "user" "users" .UsersCount}}</div>
<div><span class="num">{{largeNumFmt .CollectionsCount}}</span> {{pluralize "blog" "blogs" .CollectionsCount}}</div>
<div><span class="num">{{largeNumFmt .PostsCount}}</span> {{pluralize "post" "posts" .PostsCount}}</div>
</div>
</form>
<hr />
<h2><a name="monitor"></a>Application</h2>
<div class="ui attached table segment">
<dl class="dl-horizontal admin-dl-horizontal">
<dt>WriteFreely</dt>
<dd>{{.Version}}</dd>
<dt>Server Uptime</dt>
<dd>{{.SysStatus.Uptime}}</dd>
<dt>Current Goroutines</dt>
<dd>{{.SysStatus.NumGoroutine}}</dd>
<div class="ui divider"></div>
<dt>Current memory usage</dt>
<dd>{{.SysStatus.MemAllocated}}</dd>
<dt>Total mem allocated</dt>
<dd>{{.SysStatus.MemTotal}}</dd>
<dt>Memory obtained</dt>
<dd>{{.SysStatus.MemSys}}</dd>
<dt>Pointer lookup times</dt>
<dd>{{.SysStatus.Lookups}}</dd>
<dt>Memory allocate times</dt>
<dd>{{.SysStatus.MemMallocs}}</dd>
<dt>Memory free times</dt>
<dd>{{.SysStatus.MemFrees}}</dd>
<div class="ui divider"></div>
<dt>Current heap usage</dt>
<dd>{{.SysStatus.HeapAlloc}}</dd>
<dt>Heap memory obtained</dt>
<dd>{{.SysStatus.HeapSys}}</dd>
<dt>Heap memory idle</dt>
<dd>{{.SysStatus.HeapIdle}}</dd>
<dt>Heap memory in use</dt>
<dd>{{.SysStatus.HeapInuse}}</dd>
<dt>Heap memory released</dt>
<dd>{{.SysStatus.HeapReleased}}</dd>
<dt>Heap objects</dt>
<dd>{{.SysStatus.HeapObjects}}</dd>
<div class="ui divider"></div>
<dt>Bootstrap stack usage</dt>
<dd>{{.SysStatus.StackInuse}}</dd>
<dt>Stack memory obtained</dt>
<dd>{{.SysStatus.StackSys}}</dd>
<dt>MSpan structures in use</dt>
<dd>{{.SysStatus.MSpanInuse}}</dd>
<dt>MSpan structures obtained</dt>
<dd>{{.SysStatus.HeapSys}}</dd>
<dt>MCache structures in use</dt>
<dd>{{.SysStatus.MCacheInuse}}</dd>
<dt>MCache structures obtained</dt>
<dd>{{.SysStatus.MCacheSys}}</dd>
<dt>Profiling bucket hash table obtained</dt>
<dd>{{.SysStatus.BuckHashSys}}</dd>
<dt>GC metadata obtained</dt>
<dd>{{.SysStatus.GCSys}}</dd>
<dt>Other system allocation obtained</dt>
<dd>{{.SysStatus.OtherSys}}</dd>
<div class="ui divider"></div>
<dt>Next GC recycle</dt>
<dd>{{.SysStatus.NextGC}}</dd>
<dt>Since last GC</dt>
<dd>{{.SysStatus.LastGC}}</dd>
<dt>Total GC pause</dt>
<dd>{{.SysStatus.PauseTotalNs}}</dd>
<dt>Last GC pause</dt>
<dd>{{.SysStatus.PauseNs}}</dd>
<dt>GC times</dt>
<dd>{{.SysStatus.NumGC}}</dd>
</dl>
</div>
</div>
<script>

@ -0,0 +1,154 @@
{{define "app-settings"}}
{{template "header" .}}
<style type="text/css">
h2 {font-weight: normal;}
form {
margin: 0 0 2em;
}
form dt {
line-height: inherit;
}
.invisible {
display: none;
}
p.docs {
font-size: 0.86em;
}
</style>
<div class="content-container snug">
{{template "admin-header" .}}
{{if .Message}}<p><a name="config"></a>{{.Message}}</p>{{end}}
{{if .ConfigMessage}}<p class="success" style="text-align: center">{{.ConfigMessage}}</p>{{end}}
<form action="/admin/update/config" method="post">
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}>
Site Title
<p>Your public site name.</p>
</div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="text" name="site_name" id="site_name" class="inline" value="{{.Config.SiteName}}" style="width: 14em;"/></div>
</div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}>
Site Description
<p>Describe your site &mdash; this shows in your site's metadata.</p>
</div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="text" name="site_desc" id="site_desc" class="inline" value="{{.Config.SiteDesc}}" style="width: 14em;"/></div>
</div>
<div class="features row">
<div>
Host
<p>The address where your site lives.</p>
</div>
<div>{{.Config.Host}}</div>
</div>
<div class="features row">
<div>
Community Mode
<p>Whether your site is made for one person or many.</p>
</div>
<div>{{if .Config.SingleUser}}Single user{{else}}Multiple users{{end}}</div>
</div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}>
Landing Page
<p>The page that logged-out visitors will see first. This should be a path, e.g. <code>/read</code></p>
</div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="text" name="landing" id="landing" class="inline" value="{{.Config.Landing}}" style="width: 14em;"/></div>
</div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="open_registration">
Open Registrations
<p>Whether or not registration is open to anyone who visits the site.</p>
</label></div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="open_registration" id="open_registration" {{if .Config.OpenRegistration}}checked="checked"{{end}} />
</div>
</div>
<div class="features row">
<div><label for="min_username_len">
Minimum Username Length
<p>The minimum number of characters allowed in a username. (Recommended: 2 or more.)</p>
</label></div>
<div><input type="number" name="min_username_len" id="min_username_len" class="inline" min="1" max="100" value="{{.Config.MinUsernameLen}}"/></div>
</div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="max_blogs">
Maximum Blogs per User
<p>Keep things simple by setting this to <strong>1</strong>, unlimited by setting to <strong>0</strong>, or pick another amount.</p>
</label></div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="number" name="max_blogs" id="max_blogs" class="inline" min="0" value="{{.Config.MaxBlogs}}"/></div>
</div>
<div class="features row">
<div><label for="federation">
Federation
<p>Enable accounts on this site to propagate their posts via the ActivityPub protocol.</p>
</label></div>
<div><input type="checkbox" name="federation" id="federation" {{if .Config.Federation}}checked="checked"{{end}} /></div>
</div>
<div class="features row">
<div><label for="public_stats">
Public Stats
<p>Publicly display the number of users and posts on your <strong>About</strong> page.</p>
</label></div>
<div><input type="checkbox" name="public_stats" id="public_stats" {{if .Config.PublicStats}}checked="checked"{{end}} /></div>
</div>
<div class="features row">
<div><label for="private">
Private Instance
<p>Make this instance accessible only to those with an account.</p>
</label></div>
<div><input type="checkbox" name="private" id="private" {{if .Config.Private}}checked="checked"{{end}} /></div>
</div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="local_timeline">
Reader
<p>Show a feed of user posts for anyone who chooses to share there.</p>
</label></div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="local_timeline" id="local_timeline" {{if .Config.LocalTimeline}}checked="checked"{{end}} /></div>
</div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="user_invites">
Allow invitations from...
<p>Choose who on this instance can invite new people.</p>
</label></div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}>
<select name="user_invites" id="user_invites">
<option value="none" {{if eq .Config.UserInvites ""}}selected="selected"{{end}}>No one</option>
<option value="admin" {{if eq .Config.UserInvites "admin"}}selected="selected"{{end}}>Only Admins</option>
<option value="user" {{if eq .Config.UserInvites "user"}}selected="selected"{{end}}>All Users</option>
</select>
</div>
</div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="default_visibility">
Default blog visibility
<p>The default setting for new accounts and blogs.</p>
</label></div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}>
<select name="default_visibility" id="default_visibility">
<option value="unlisted" {{if eq .Config.DefaultVisibility "unlisted"}}selected="selected"{{end}}>Unlisted</option>
<option value="public" {{if eq .Config.DefaultVisibility "public"}}selected="selected"{{end}}>Public</option>
<option value="private" {{if eq .Config.DefaultVisibility "private"}}selected="selected"{{end}}>Private</option>
</select>
</div>
</div>
<div class="features row">
<input type="submit" value="Save Settings" />
</div>
</form>
<p class="docs">Still have questions? Read more details in the <a href="https://writefreely.org/docs/{{.OfficialVersion}}/admin/config">configuration docs</a>.</p>
</div>
<script>
history.replaceState(null, "", "/admin/settings"+window.location.hash);
</script>
{{template "footer" .}}
{{template "body-end" .}}
{{end}}

@ -0,0 +1,48 @@
{{define "app-updates"}}
{{template "header" .}}
<style type="text/css">
p.intro {
text-align: left;
}
p.disabled {
font-style: italic;
color: #999;
}
</style>
<div class="content-container snug">
{{template "admin-header" .}}
{{ if .UpdateChecks }}
{{if .CheckFailed}}
<p class="intro"><span class="ex failure">&times;</span> Automated update check failed.</p>
<p>Installed version: <strong>{{.Version}}</strong> (<a href="{{.CurReleaseNotesURL}}" target="changelog-wf">release notes</a>).</p>
<p>Learn about latest releases on the <a href="https://blog.writefreely.org/tag:release" target="changelog-wf">WriteFreely blog</a> or <a href="https://discuss.write.as/c/writefreely/updates" target="forum-wf">forum</a>.</p>
{{else if not .UpdateAvailable}}
<p class="intro"><span class="check">&check;</span> WriteFreely is <strong>up to date</strong>.</p>
<p>Installed version: <strong>{{.Version}}</strong> (<a href="{{.LatestReleaseNotesURL}}" target="changelog-wf">release notes</a>).</p>
{{else}}
<p class="intro">A new version of WriteFreely is available! <a href="{{.LatestReleaseURL}}" target="download-wf" style="font-weight: bold;">Get {{.LatestVersion}}</a></p>
<p class="changelog">
<a href="{{.LatestReleaseNotesURL}}" target="changelog-wf">Read the release notes</a> for details on features, bug fixes, and notes on upgrading from your current version, <strong>{{.Version}}</strong>.
</p>
{{end}}
<p style="font-size: 0.86em;"><em>Last checked</em>: <time class="dt-published" datetime="{{.LastChecked8601}}">{{.LastChecked}}</time>. <a href="/admin/updates?check=now">Check now</a>.</p>
<script>
// Code modified from /js/localdate.js
var displayEl = document.querySelector("time");
var d = new Date(displayEl.getAttribute("datetime"));
displayEl.textContent = d.toLocaleDateString(navigator.language || "en-US", { dateStyle: 'long', timeStyle: 'short' });
</script>
{{ else }}
<p class="intro disabled">Automated update checks are disabled.</p>
<p>Installed version: <strong>{{.Version}}</strong> (<a href="{{.CurReleaseNotesURL}}" target="changelog-wf">release notes</a>).</p>
<p>Learn about latest releases on the <a href="https://blog.writefreely.org/tag:release" target="changelog-wf">WriteFreely blog</a> or <a href="https://discuss.write.as/c/writefreely/updates" target="forum-wf">forum</a>.</p>
{{ end }}
{{template "footer" .}}
{{template "body-end" .}}
{{end}}

@ -0,0 +1,105 @@
{{define "monitor"}}
{{template "header" .}}
<style type="text/css">
h2 {font-weight: normal;}
.ui.divider:not(.vertical):not(.horizontal) {
border-top: 1px solid rgba(34,36,38,.15);
border-bottom: 1px solid rgba(255,255,255,.1);
}
.ui.divider {
margin: 1rem 0;
line-height: 1;
height: 0;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: rgba(0,0,0,.85);
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
font-size: 1rem;
}
</style>
<div class="content-container snug">
{{template "admin-header" .}}
{{if .Message}}<p>{{.Message}}</p>{{end}}
<h2><a name="monitor"></a>Application Monitor</h2>
<div class="ui attached table segment">
<dl class="dl-horizontal admin-dl-horizontal">
<dt>WriteFreely</dt>
<dd>{{.Version}}</dd>
<dt>Server Uptime</dt>
<dd>{{.SysStatus.Uptime}}</dd>
<dt>Current Goroutines</dt>
<dd>{{.SysStatus.NumGoroutine}}</dd>
<div class="ui divider"></div>
<dt>Current memory usage</dt>
<dd>{{.SysStatus.MemAllocated}}</dd>
<dt>Total mem allocated</dt>
<dd>{{.SysStatus.MemTotal}}</dd>
<dt>Memory obtained</dt>
<dd>{{.SysStatus.MemSys}}</dd>
<dt>Pointer lookup times</dt>
<dd>{{.SysStatus.Lookups}}</dd>
<dt>Memory allocate times</dt>
<dd>{{.SysStatus.MemMallocs}}</dd>
<dt>Memory free times</dt>
<dd>{{.SysStatus.MemFrees}}</dd>
<div class="ui divider"></div>
<dt>Current heap usage</dt>
<dd>{{.SysStatus.HeapAlloc}}</dd>
<dt>Heap memory obtained</dt>
<dd>{{.SysStatus.HeapSys}}</dd>
<dt>Heap memory idle</dt>
<dd>{{.SysStatus.HeapIdle}}</dd>
<dt>Heap memory in use</dt>
<dd>{{.SysStatus.HeapInuse}}</dd>
<dt>Heap memory released</dt>
<dd>{{.SysStatus.HeapReleased}}</dd>
<dt>Heap objects</dt>
<dd>{{.SysStatus.HeapObjects}}</dd>
<div class="ui divider"></div>
<dt>Bootstrap stack usage</dt>
<dd>{{.SysStatus.StackInuse}}</dd>
<dt>Stack memory obtained</dt>
<dd>{{.SysStatus.StackSys}}</dd>
<dt>MSpan structures in use</dt>
<dd>{{.SysStatus.MSpanInuse}}</dd>
<dt>MSpan structures obtained</dt>
<dd>{{.SysStatus.HeapSys}}</dd>
<dt>MCache structures in use</dt>
<dd>{{.SysStatus.MCacheInuse}}</dd>
<dt>MCache structures obtained</dt>
<dd>{{.SysStatus.MCacheSys}}</dd>
<dt>Profiling bucket hash table obtained</dt>
<dd>{{.SysStatus.BuckHashSys}}</dd>
<dt>GC metadata obtained</dt>
<dd>{{.SysStatus.GCSys}}</dd>
<dt>Other system allocation obtained</dt>
<dd>{{.SysStatus.OtherSys}}</dd>
<div class="ui divider"></div>
<dt>Next GC recycle</dt>
<dd>{{.SysStatus.NextGC}}</dd>
<dt>Since last GC</dt>
<dd>{{.SysStatus.LastGC}}</dd>
<dt>Total GC pause</dt>
<dd>{{.SysStatus.PauseTotalNs}}</dd>
<dt>Last GC pause</dt>
<dd>{{.SysStatus.PauseNs}}</dd>
<dt>GC times</dt>
<dd>{{.SysStatus.NumGC}}</dd>
</dl>
</div>
</div>
{{template "footer" .}}
{{template "body-end" .}}
{{end}}

@ -4,7 +4,10 @@
<div class="snug content-container">
{{template "admin-header" .}}
<h2 id="posts-header" style="display: flex; justify-content: space-between;">Users <span style="font-style: italic; font-size: 0.75em;">{{.TotalUsers}} total</strong></h2>
<div class="row admin-actions" style="justify-content: space-between;">
<span style="font-style: italic; font-size: 1.2em">{{.TotalUsers}} {{pluralize "user" "users" .TotalUsers}}</span>
<a class="btn cta" href="/me/invites">+ Invite people</a>
</div>
<table class="classy export" style="width:100%">
<tr>

@ -71,8 +71,7 @@ input.copy-text {
</tr>
<tr>
<form action="/admin/user/{{.User.Username}}/status" method="POST" {{if not .User.IsSilenced}}onsubmit="return confirmSilence()"{{end}}>
<a id="status"/>
<th>Status</th>
<th><a id="status"></a>Status</th>
<td class="active-silence">
{{if .User.IsSilenced}}
<p>Silenced</p>

@ -10,7 +10,7 @@
{{template "user-silenced"}}
{{end}}
<h2 id="posts-header">drafts</h2>
<h1 id="posts-header">Drafts</h1>
{{ if .AnonymousPosts }}
<p>These are your draft posts. You can share them individually (without a blog) or move them to your blog when you're ready.</p>

@ -10,7 +10,7 @@
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
<h2>blogs</h2>
<h1>Blogs</h1>
<ul class="atoms collections">
{{range $i, $el := .Collections}}<li class="collection"><h3>
<a class="title" href="/{{.Alias}}/">{{if .Title}}{{.Title}}{{else}}{{.Alias}}{{end}}</a>

@ -2,7 +2,7 @@
{{template "header" .}}
<div class="snug content-container">
<h2 id="posts-header">Export</h2>
<h1 id="posts-header">Export</h1>
<p>Your data on {{.SiteName}} is always free. Download and back-up your work any time.</p>
<table class="classy export">

@ -97,11 +97,16 @@
{{define "admin-header"}}
<header class="admin">
<h1>Admin</h1>
<nav id="admin">
<nav id="admin" class="pager">
<a href="/admin" {{if eq .Path "/admin"}}class="selected"{{end}}>Dashboard</a>
<a href="/admin/settings" {{if eq .Path "/admin/settings"}}class="selected"{{end}}>Settings</a>
{{if not .SingleUser}}
<a href="/admin/users" {{if eq .Path "/admin/users"}}class="selected"{{end}}>Users</a>
<a href="/admin/pages" {{if eq .Path "/admin/pages"}}class="selected"{{end}}>Pages</a>
{{if .UpdateChecks}}<a href="/admin/updates" {{if eq .Path "/admin/updates"}}class="selected"{{end}}>Updates{{if .UpdateAvailable}}<span class="blip">!</span>{{end}}</a>{{end}}
{{end}}
{{if not .Forest}}
<a href="/admin/monitor" {{if eq .Path "/admin/monitor"}}class="selected"{{end}}>Monitor</a>
{{end}}
</nav>
</header>

@ -6,11 +6,11 @@
h3 { font-weight: normal; }
.section > *:not(input) { font-size: 0.86em; }
</style>
<div class="content-container snug regular">
<div class="content-container snug">
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
<h2>{{if .IsLogOut}}Before you go...{{else}}Account Settings {{if .IsAdmin}}<a href="/admin">admin settings</a>{{end}}{{end}}</h2>
<h1>{{if .IsLogOut}}Before you go...{{else}}Account Settings {{if .IsAdmin}}<a href="/admin">admin settings</a>{{end}}{{end}}</h1>
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
@ -20,7 +20,7 @@ h3 { font-weight: normal; }
<p class="introduction">Please add an <strong>email address</strong> and/or <strong>passphrase</strong> so you can log in again later.</p>
</div>
{{ else }}
<div class="option">
<div>
<p>Change your account settings here.</p>
</div>

@ -0,0 +1,131 @@
/*
* 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 writefreely
import (
"github.com/writeas/web-core/log"
"io/ioutil"
"net/http"
"strings"
"sync"
"time"
)
// updatesCacheTime is the default interval between cache updates for new
// software versions
const defaultUpdatesCacheTime = 12 * time.Hour
// updatesCache holds data about current and new releases of the writefreely
// software
type updatesCache struct {
mu sync.Mutex
frequency time.Duration
lastCheck time.Time
latestVersion string
currentVersion string
checkError error
}
// CheckNow asks for the latest released version of writefreely and updates
// the cache last checked time. If the version postdates the current 'latest'
// the version value is replaced.
func (uc *updatesCache) CheckNow() error {
if debugging {
log.Info("[update check] Checking for update now.")
}
uc.mu.Lock()
defer uc.mu.Unlock()
uc.lastCheck = time.Now()
latestRemote, err := newVersionCheck()
if err != nil {
log.Error("[update check] Failed: %v", err)
uc.checkError = err
return err
}
if CompareSemver(latestRemote, uc.latestVersion) == 1 {
uc.latestVersion = latestRemote
}
return nil
}
// AreAvailable updates the cache if the frequency duration has passed
// then returns if the latest release is newer than the current running version.
func (uc updatesCache) AreAvailable() bool {
if time.Since(uc.lastCheck) > uc.frequency {
uc.CheckNow()
}
return CompareSemver(uc.latestVersion, uc.currentVersion) == 1
}
// AreAvailableNoCheck returns if the latest release is newer than the current
// running version.
func (uc updatesCache) AreAvailableNoCheck() bool {
return CompareSemver(uc.latestVersion, uc.currentVersion) == 1
}
// LatestVersion returns the latest stored version available.
func (uc updatesCache) LatestVersion() string {
return uc.latestVersion
}
func (uc updatesCache) ReleaseURL() string {
return "https://writefreely.org/releases/" + uc.latestVersion
}
// ReleaseNotesURL returns the full URL to the blog.writefreely.org release notes
// for the latest version as stored in the cache.
func (uc updatesCache) ReleaseNotesURL() string {
return wfReleaseNotesURL(uc.latestVersion)
}
func wfReleaseNotesURL(v string) string {
ver := strings.TrimPrefix(v, "v")
ver = strings.TrimSuffix(ver, ".0")
// hack until go 1.12 in build/travis
seg := strings.Split(ver, ".")
return "https://blog.writefreely.org/version-" + strings.Join(seg, "-")
}
// newUpdatesCache returns an initialized updates cache
func newUpdatesCache(expiry time.Duration) *updatesCache {
cache := updatesCache{
frequency: expiry,
currentVersion: "v" + softwareVer,
}
go cache.CheckNow()
return &cache
}
// InitUpdates initializes the updates cache, if the config value is set
// It uses the defaultUpdatesCacheTime for the cache expiry
func (app *App) InitUpdates() {
if app.cfg.App.UpdateChecks {
app.updates = newUpdatesCache(defaultUpdatesCacheTime)
}
}
func newVersionCheck() (string, error) {
res, err := http.Get("https://version.writefreely.org")
if debugging {
log.Info("[update check] GET https://version.writefreely.org")
}
// TODO: return error if statusCode != OK
if err == nil && res.StatusCode == http.StatusOK {
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", err
}
return string(body), nil
}
return "", err
}

@ -0,0 +1,82 @@
package writefreely
import (
"regexp"
"testing"
"time"
)
func TestUpdatesRoundTrip(t *testing.T) {
cache := newUpdatesCache(defaultUpdatesCacheTime)
t.Run("New Updates Cache", func(t *testing.T) {
if cache == nil {
t.Fatal("Returned nil cache")
}
if cache.frequency != defaultUpdatesCacheTime {
t.Fatalf("Got cache expiry frequency: %s but expected: %s", cache.frequency, defaultUpdatesCacheTime)
}
if cache.currentVersion != "v"+softwareVer {
t.Fatalf("Got current version: %s but expected: %s", cache.currentVersion, "v"+softwareVer)
}
})
t.Run("Release URL", func(t *testing.T) {
url := cache.ReleaseNotesURL()
reg, err := regexp.Compile(`^https:\/\/blog.writefreely.org\/version(-\d+){1,}$`)
if err != nil {
t.Fatalf("Test Case Error: Failed to compile regex: %v", err)
}
match := reg.MatchString(url)
if !match {
t.Fatalf("Malformed Release URL: %s", url)
}
})
t.Run("Check Now", func(t *testing.T) {
// ensure time between init and next check
time.Sleep(1 * time.Second)
prevLastCheck := cache.lastCheck
// force to known older version for latest and current
prevLatestVer := "v0.8.1"
cache.latestVersion = prevLatestVer
cache.currentVersion = "v0.8.0"
err := cache.CheckNow()
if err != nil {
t.Fatalf("Error should be nil, got: %v", err)
}
if prevLastCheck == cache.lastCheck {
t.Fatal("Expected lastCheck to update")
}
if cache.lastCheck.Before(prevLastCheck) {
t.Fatal("Last check should be newer than previous")
}
if prevLatestVer == cache.latestVersion {
t.Fatal("expected latestVersion to update")
}
})
t.Run("Are Available", func(t *testing.T) {
if !cache.AreAvailable() {
t.Fatalf("Cache reports not updates but Current is %s and Latest is %s", cache.currentVersion, cache.latestVersion)
}
})
t.Run("Latest Version", func(t *testing.T) {
gotLatest := cache.LatestVersion()
if gotLatest != cache.latestVersion {
t.Fatalf("Malformed latest version. Expected: %s but got: %s", cache.latestVersion, gotLatest)
}
})
}
Loading…
Cancel
Save