mirror of
https://github.com/writefreely/writefreely
synced 2024-11-28 03:20:17 +00:00
Merge branch 'develop' into T713-oauth-account-management
This commit is contained in:
commit
cf4f08b264
51 changed files with 1917 additions and 372 deletions
101
CONTRIBUTING.md
101
CONTRIBUTING.md
|
@ -1,26 +1,99 @@
|
|||
# Contributing to WriteFreely
|
||||
|
||||
Welcome! We're glad you're interested in contributing to the WriteFreely project.
|
||||
Welcome! We're glad you're interested in contributing to WriteFreely.
|
||||
|
||||
To start, we'd suggest checking out [our Phabricator board](https://phabricator.write.as/tag/write_freely/) to see where the project is at and where it's going. You can also [join the WriteFreely forums](https://discuss.write.as/c/writefreely) to start talking about what you'd like to do or see.
|
||||
For **questions**, **help**, **feature requests**, and **general discussion**, please use [our forum](https://discuss.write.as).
|
||||
|
||||
## Asking Questions
|
||||
For **bug reports**, please [open a GitHub issue](https://github.com/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
|
||||
|
|
16
account.go
16
account.go
|
@ -302,12 +302,14 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
p := &struct {
|
||||
page.StaticPage
|
||||
To string
|
||||
Message template.HTML
|
||||
Flashes []template.HTML
|
||||
LoginUsername string
|
||||
OauthSlack bool
|
||||
OauthWriteAs bool
|
||||
To string
|
||||
Message template.HTML
|
||||
Flashes []template.HTML
|
||||
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)
|
||||
}
|
||||
|
|
135
admin.go
135
admin.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -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
|
||||
|
||||
|
@ -131,9 +195,10 @@ func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
|||
TotalUsers int64
|
||||
TotalPages []int
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Users", nil),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
UserPage: NewUserPage(app, r, u, "Users", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
}
|
||||
|
||||
p.TotalUsers = app.db.GetAllUsersCount()
|
||||
|
@ -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,9 +245,10 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
|||
TotalPosts int64
|
||||
ClearEmail string
|
||||
}{
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
Colls: []inspectedCollection{},
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
Colls: []inspectedCollection{},
|
||||
}
|
||||
|
||||
var err error
|
||||
|
@ -304,14 +371,16 @@ func handleAdminResetUserPass(app *App, u *User, w http.ResponseWriter, r *http.
|
|||
func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
|
||||
Pages []*instanceContent
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Pages", nil),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
UserPage: NewUserPage(app, r, u, "Pages", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
}
|
||||
|
||||
var err error
|
||||
|
@ -368,14 +437,16 @@ func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
|||
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
|
||||
Banner *instanceContent
|
||||
Content *instanceContent
|
||||
}{
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
}
|
||||
|
||||
var err error
|
||||
|
@ -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
|
||||
}
|
||||
|
|
3
app.go
3
app.go
|
@ -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()
|
||||
|
|
61
cmd/writefreely/config.go
Normal file
61
cmd/writefreely/config.go
Normal file
|
@ -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
|
||||
}
|
50
cmd/writefreely/db.go
Normal file
50
cmd/writefreely/db.go
Normal file
|
@ -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)
|
||||
}
|
39
cmd/writefreely/keys.go
Normal file
39
cmd/writefreely/keys.go
Normal file
|
@ -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")
|
||||
cli.VersionPrinter = func(c *cli.Context) {
|
||||
fmt.Printf("%s\n", c.App.Version)
|
||||
}
|
||||
app := &cli.App{
|
||||
Name: "WriteFreely",
|
||||
Usage: "A beautifully pared-down blogging platform",
|
||||
Version: writefreely.FormatVersion(),
|
||||
Action: legacyActions, // legacy due to use of flags for switching actions
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "create-config",
|
||||
Value: false,
|
||||
Usage: "Generate a basic configuration",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "config",
|
||||
Value: false,
|
||||
Usage: "Interactive configuration process",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "sections",
|
||||
Value: "server db app",
|
||||
Usage: "Which sections of the configuration to go through (requires --config)\n" +
|
||||
"valid values are any combination of 'server', 'db' and 'app' \n" +
|
||||
"example: writefreely --config --sections \"db app\"",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "gen-keys",
|
||||
Value: false,
|
||||
Usage: "Generate encryption and authentication keys",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "init-db",
|
||||
Value: false,
|
||||
Usage: "Initialize app database",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migrate",
|
||||
Value: false,
|
||||
Usage: "Migrate the database",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "create-admin",
|
||||
Usage: "Create an admin with the given username:password",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "create-user",
|
||||
Usage: "Create a regular user with the given username:password",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "delete-user",
|
||||
Usage: "Delete a user with the given username",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "reset-pass",
|
||||
Usage: "Reset the given user's password",
|
||||
Hidden: true,
|
||||
},
|
||||
}, // legacy flags (set to hidden to eventually switch to bash-complete compatible format)
|
||||
}
|
||||
|
||||
// Setup actions
|
||||
createConfig := flag.Bool("create-config", false, "Creates a basic configuration and exits")
|
||||
doConfig := flag.Bool("config", false, "Run the configuration process")
|
||||
configSections := flag.String("sections", "server db app", "Which sections of the configuration to go through (requires --config), "+
|
||||
"valid values are any combination of 'server', 'db' and 'app' "+
|
||||
"example: writefreely --config --sections \"db app\"")
|
||||
genKeys := flag.Bool("gen-keys", false, "Generate encryption and authentication keys")
|
||||
createSchema := flag.Bool("init-db", false, "Initialize app database")
|
||||
migrate := flag.Bool("migrate", false, "Migrate the database")
|
||||
defaultFlags := []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "c",
|
||||
Value: "config.ini",
|
||||
Usage: "Load configuration from `FILE`",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Value: false,
|
||||
Usage: "Enables debug logging",
|
||||
},
|
||||
}
|
||||
|
||||
// Admin actions
|
||||
createAdmin := flag.String("create-admin", "", "Create an admin with the given username:password")
|
||||
createUser := flag.String("create-user", "", "Create a regular user with the given username:password")
|
||||
deleteUsername := flag.String("delete-user", "", "Delete a user with the given username")
|
||||
resetPassUser := flag.String("reset-pass", "", "Reset the given user's password")
|
||||
outputVersion := flag.Bool("v", false, "Output the current version")
|
||||
flag.Parse()
|
||||
app.Flags = append(app.Flags, defaultFlags...)
|
||||
|
||||
app := writefreely.NewApp(*configFile)
|
||||
app.Commands = []*cli.Command{
|
||||
&cmdUser,
|
||||
&cmdDB,
|
||||
&cmdConfig,
|
||||
&cmdKeys,
|
||||
&cmdServe,
|
||||
}
|
||||
|
||||
if *outputVersion {
|
||||
writefreely.OutputVersion()
|
||||
os.Exit(0)
|
||||
} else if *createConfig {
|
||||
err := writefreely.CreateConfig(app)
|
||||
err := app.Run(os.Args)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func legacyActions(c *cli.Context) error {
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
|
||||
switch true {
|
||||
case c.IsSet("create-config"):
|
||||
return writefreely.CreateConfig(app)
|
||||
case c.IsSet("config"):
|
||||
writefreely.DoConfig(app, c.String("sections"))
|
||||
return nil
|
||||
case c.IsSet("gen-keys"):
|
||||
return writefreely.GenerateKeyFiles(app)
|
||||
case c.IsSet("init-db"):
|
||||
return writefreely.CreateSchema(app)
|
||||
case c.IsSet("migrate"):
|
||||
return writefreely.Migrate(app)
|
||||
case c.IsSet("create-admin"):
|
||||
username, password, err := parseCredentials(c.String("create-admin"))
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *doConfig {
|
||||
writefreely.DoConfig(app, *configSections)
|
||||
os.Exit(0)
|
||||
} else if *genKeys {
|
||||
err := writefreely.GenerateKeyFiles(app)
|
||||
return writefreely.CreateUser(app, username, password, true)
|
||||
case c.IsSet("create-user"):
|
||||
username, password, err := parseCredentials(c.String("create-user"))
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *createSchema {
|
||||
err := writefreely.CreateSchema(app)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *createAdmin != "" {
|
||||
username, password, err := userPass(*createAdmin, true)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
err = writefreely.CreateUser(app, username, password, true)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *createUser != "" {
|
||||
username, password, err := userPass(*createUser, false)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
err = writefreely.CreateUser(app, username, password, false)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *resetPassUser != "" {
|
||||
err := writefreely.ResetPassword(app, *resetPassUser)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *deleteUsername != "" {
|
||||
err := writefreely.DoDeleteAccount(app, *deleteUsername)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *migrate {
|
||||
err := writefreely.Migrate(app)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
return writefreely.CreateUser(app, username, password, false)
|
||||
case c.IsSet("delete-user"):
|
||||
return writefreely.DoDeleteAccount(app, c.String("delete-user"))
|
||||
case c.IsSet("reset-pass"):
|
||||
return writefreely.ResetPassword(app, c.String("reset-pass"))
|
||||
}
|
||||
|
||||
// Initialize the application
|
||||
var err error
|
||||
log.Info("Starting %s...", writefreely.FormatVersion())
|
||||
app, err = writefreely.Initialize(app, *debugPtr)
|
||||
app, err = writefreely.Initialize(app, c.Bool("debug"))
|
||||
if err != nil {
|
||||
log.Error("%s", err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
|
||||
// Set app routes
|
||||
|
@ -136,20 +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"
|
||||
}
|
||||
err = fmt.Errorf("usage: writefreely --create-%s username:password", c)
|
||||
return
|
||||
return "", "", fmt.Errorf("invalid format for passed credentials, must be username:password")
|
||||
}
|
||||
|
||||
user = creds[0]
|
||||
pass = creds[1]
|
||||
return
|
||||
return creds[0], creds[1], nil
|
||||
}
|
||||
|
|
97
cmd/writefreely/user.go
Normal file
97
cmd/writefreely/user.go
Normal file
|
@ -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)
|
||||
}
|
49
cmd/writefreely/web.go
Normal file
49
cmd/writefreely/web.go
Normal file
|
@ -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)
|
||||
}
|
||||
|
|
26
db/create.go
26
db/create.go
|
@ -1,3 +1,13 @@
|
|||
/*
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
@ -139,6 +149,15 @@ func (c *Column) SetDefault(value string) *Column {
|
|||
return c
|
||||
}
|
||||
|
||||
func (c *Column) SetDefaultCurrentTimestamp() *Column {
|
||||
def := "NOW()"
|
||||
if c.Dialect == DialectSQLite {
|
||||
def = "CURRENT_TIMESTAMP"
|
||||
}
|
||||
c.Default = OptionalString{Set: true, Value: def}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Column) SetType(t ColumnType) *Column {
|
||||
c.Type = t
|
||||
return c
|
||||
|
@ -168,7 +187,11 @@ func (c *Column) String() (string, error) {
|
|||
|
||||
if c.Default.Set {
|
||||
str.WriteString(" DEFAULT ")
|
||||
str.WriteString(c.Default.Value)
|
||||
val := c.Default.Value
|
||||
if val == "" {
|
||||
val = "''"
|
||||
}
|
||||
str.WriteString(val)
|
||||
}
|
||||
|
||||
if c.PrimaryKey {
|
||||
|
@ -241,4 +264,3 @@ func (b *CreateTableSqlBuilder) ToSQL() (string, error) {
|
|||
|
||||
return str.String(), nil
|
||||
}
|
||||
|
||||
|
|
3
go.mod
3
go.mod
|
@ -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
|
||||
)
|
||||
|
||||
|
|
10
go.sum
10
go.sum
|
@ -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;
|
||||
}
|
||||
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").
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"provider",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")),
|
||||
dialect.
|
||||
AlterTable("oauth_users").
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"client_id",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")),
|
||||
dialect.
|
||||
AlterTable("oauth_users").
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"access_token",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 512}).SetDefault("")),
|
||||
dialect.CreateUniqueIndex("oauth_users_uk", "oauth_users", "user_id", "provider", "client_id"),
|
||||
}
|
||||
|
||||
if dialect != wf_db.DialectSQLite {
|
||||
// This updates the length of the `remote_user_id` column. It isn't needed for SQLite databases.
|
||||
builders = append(builders, dialect.
|
||||
AlterTable("oauth_users").
|
||||
ChangeColumn("remote_user_id",
|
||||
dialect.
|
||||
Column(
|
||||
"remote_user_id",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 128,})).
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"provider",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 24,})).
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"client_id",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 128,})).
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"access_token",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 512,})),
|
||||
dialect.DropIndex("remote_user_id", "oauth_users"),
|
||||
dialect.DropIndex("user_id", "oauth_users"),
|
||||
dialect.CreateUniqueIndex("oauth_users", "oauth_users", "user_id", "provider", "client_id"),
|
||||
wf_db.OptionalInt{Set: true, Value: 128})))
|
||||
}
|
||||
|
||||
for _, builder := range builders {
|
||||
query, err := builder.ToSQL()
|
||||
if err != nil {
|
||||
|
|
30
oauth.go
30
oauth.go
|
@ -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(),
|
||||
|
|
115
oauth_gitlab.go
Normal file
115
oauth_gitlab.go
Normal file
|
@ -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">
|
||||
|
|
25
posts.go
25
posts.go
|
@ -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
|
||||
}
|
||||
urls[u] = true
|
||||
// Ensure the path looks like it leads to an image file
|
||||
if !imageURLRegex.MatchString(u.Path) {
|
||||
continue
|
||||
}
|
||||
urls[uRaw] = true
|
||||
}
|
||||
|
||||
resURLs := make([]string, 0)
|
||||
for k := range urls {
|
||||
resURLs = append(resURLs, k)
|
||||
}
|
||||
p.Images = resURLs
|
||||
return resURLs
|
||||
}
|
||||
|
|
16
read.go
16
read.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -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))
|
||||
|
|
315
semver.go
Normal file
315
semver.go
Normal file
|
@ -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,9 +20,12 @@
|
|||
"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
|
||||
function highlight(nodes) {
|
||||
for (i=0; i < nodes.length; i++) {
|
||||
|
|
|
@ -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>
|
||||
|
|
154
templates/user/admin/app-settings.tmpl
Normal file
154
templates/user/admin/app-settings.tmpl
Normal file
|
@ -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 — 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}}
|
48
templates/user/admin/app-updates.tmpl
Normal file
48
templates/user/admin/app-updates.tmpl
Normal file
|
@ -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">×</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">✓</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}}
|
105
templates/user/admin/monitor.tmpl
Normal file
105
templates/user/admin/monitor.tmpl
Normal file
|
@ -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>
|
||||
|
||||
|
|
131
updates.go
Normal file
131
updates.go
Normal file
|
@ -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
|
||||
}
|
82
updates_test.go
Normal file
82
updates_test.go
Normal file
|
@ -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…
Reference in a new issue