mirror of
https://github.com/writefreely/writefreely
synced 2024-11-10 11:24:13 +00:00
Merge branch 'develop' into gopher
This commit is contained in:
commit
6dbc753ecb
68 changed files with 1609 additions and 400 deletions
1
Makefile
1
Makefile
|
@ -86,6 +86,7 @@ release : clean ui assets
|
|||
cp -r templates $(BUILDPATH)
|
||||
cp -r pages $(BUILDPATH)
|
||||
cp -r static $(BUILDPATH)
|
||||
scripts/invalidate-css.sh $(BUILDPATH)
|
||||
mkdir $(BUILDPATH)/keys
|
||||
$(MAKE) build-linux
|
||||
mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
|
|
69
README.md
69
README.md
|
@ -7,81 +7,76 @@
|
|||
<a href="https://github.com/writeas/writefreely/releases/">
|
||||
<img src="https://img.shields.io/github/release/writeas/writefreely.svg" alt="Latest release" />
|
||||
</a>
|
||||
<a href="https://goreportcard.com/report/github.com/writeas/writefreely">
|
||||
<img src="https://goreportcard.com/badge/github.com/writeas/writefreely" alt="Go Report Card" />
|
||||
</a>
|
||||
<a href="https://travis-ci.org/writeas/writefreely">
|
||||
<img src="https://travis-ci.org/writeas/writefreely.svg" alt="Build status" />
|
||||
</a>
|
||||
<a href="https://github.com/writeas/writefreely/releases/latest">
|
||||
<img src="https://img.shields.io/github/downloads/writeas/writefreely/total.svg" />
|
||||
</a>
|
||||
<a href="https://goreportcard.com/report/github.com/writeas/writefreely">
|
||||
<img src="https://goreportcard.com/badge/github.com/writeas/writefreely" alt="Go Report Card" />
|
||||
</a>
|
||||
<a href="https://hub.docker.com/r/writeas/writefreely/">
|
||||
<img src="https://img.shields.io/docker/pulls/writeas/writefreely.svg" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
WriteFreely is a beautifully pared-down blogging platform that's simple on the surface, yet powerful underneath.
|
||||
WriteFreely is free and open source software for building **a writing space** on the web — whether a publication, internal blog, or writing community in the fediverse.
|
||||
|
||||
It's designed to be flexible and share your writing widely, so it's built around plain text and can publish to the _fediverse_ via ActivityPub. It's easy to install and light enough to run on a Raspberry Pi.
|
||||
![](https://writefreely.org/img/screens/pencil-reader.png)
|
||||
|
||||
[Try the editor](https://write.as/new)
|
||||
[Try the writing experience](https://write.as/new)
|
||||
|
||||
[Find an instance](https://writefreely.org/instances)
|
||||
|
||||
## Features
|
||||
|
||||
* Start a blog for yourself, or host a community of writers
|
||||
* Form larger federated networks, and interact over modern protocols like ActivityPub
|
||||
* Write on a fast, dead-simple, and distraction-free editor
|
||||
* [Format text](https://howto.write.as/getting-started) with Markdown
|
||||
* [Organize posts](https://howto.write.as/organization) with hashtags
|
||||
* Create [static pages](https://howto.write.as/creating-a-static-page)
|
||||
* Publish drafts and let others proofread them by sharing a private link
|
||||
* Create multiple lightweight blogs under a single account
|
||||
* Export all data in plain text files
|
||||
* Read a stream of other posts in your writing community
|
||||
* Build more advanced apps and extensions with the [well-documented API](https://developers.write.as/docs/api/)
|
||||
* Designed around user privacy and consent
|
||||
### Made for writing
|
||||
|
||||
## Hosting
|
||||
Built on a plain, auto-saving editor, WriteFreely gives you a distraction-free writing environment. Once published, your words are front and center, and easy to read.
|
||||
|
||||
We offer two kinds of hosting services that make WriteFreely deployment painless: [Write.as Pro](https://write.as/pro) for individuals, and [Write.as for Teams](https://write.as/for/teams) for businesses. Besides saving you time and effort, both services directly fund WriteFreely development and ensure the long-term sustainability of our open source work.
|
||||
### A connected community
|
||||
|
||||
### [![Write.as Pro](https://writefreely.org/img/writeas-pro-readme.png)](https://write.as/pro)
|
||||
Start writing together, publicly or privately. Connect with other communities, whether running WriteFreely, [Plume](https://joinplu.me/), or other ActivityPub-powered software. And bring members on board from your existing platforms, thanks to our OAuth 2.0 support.
|
||||
|
||||
Start a personal blog on [Write.as](https://write.as), our flagship instance. Built to eliminate setup friction and preserve your privacy, Write.as helps you start a blog in seconds. It supports custom domains (with SSL) and multiple blogs / pen names per account. [Read more here](https://write.as/pro).
|
||||
### Intuitive organization
|
||||
|
||||
### [![Write.as for Teams](https://writefreely.org/img/writeas-for-teams-readme.png)](https://write.as/for/teams)
|
||||
Categorize articles [with hashtags](https://writefreely.org/docs/latest/writer/hashtags), and create static pages from normal posts by [_pinning_ them](https://writefreely.org/docs/latest/writer/static) to your blog. Create draft posts and publish to multiple blogs from one account.
|
||||
|
||||
[Write.as for Teams](https://write.as/for/teams) gives your organization, business, or [open source project](https://write.as/for/open-source) a clutter-free space to share updates or proposals and build your collective knowledge. We take care of hosting, upgrades, backups, and maintenance so your team can focus on writing.
|
||||
### International
|
||||
|
||||
Blog elements are localized in 20+ languages, and WriteFreely includes first-class support for non-Latin and right-to-left (RTL) script languages.
|
||||
|
||||
### Private by default
|
||||
|
||||
WriteFreely collects minimal data, and never publicizes more than a writer consents to. Writers can seamlessly create multiple blogs from a single account for different pen names or purposes without publicly revealing their association.
|
||||
|
||||
<h2><a href="https://write.as/writefreely"><img src="https://writefreely.org/img/writeas-readme.png" height="32px" alt="Write.as" /></a></h2>
|
||||
|
||||
The quickest way to deploy WriteFreely is with [Write.as](https://write.as/writefreely), a hosted service from the team behind WriteFreely. You'll get fully-managed installation, backup, upgrades, and maintenance — and directly fund our free software work ❤️
|
||||
|
||||
[**Learn more on Write.as**](https://write.as/writefreely).
|
||||
|
||||
## Quick start
|
||||
|
||||
WriteFreely has minimal requirements to get up and running — you only need to be able to run an executable.
|
||||
WriteFreely deploys as a static binary on any platform and architecture that Go supports. Just use our built-in SQLite support, or add a MySQL database, and you'll be up and running!
|
||||
|
||||
> **Note** this is currently alpha software. We're quickly moving out of this v0.x stage, but while we're in it, there are no guarantees that this is ready for production use.
|
||||
For common platforms, start with our [pre-built binaries](https://github.com/writeas/writefreely/releases/) and head over to our [installation guide](https://writefreely.org/start) to get started.
|
||||
|
||||
To get started, head over to our [Getting Started guide](https://writefreely.org/start). For production use, jump to the [Running in Production](https://writefreely.org/start#production) section.
|
||||
### Packages
|
||||
|
||||
## Packages
|
||||
|
||||
WriteFreely is available in these package repositories:
|
||||
You can also find WriteFreely in these package repositories, thanks to our wonderful community!
|
||||
|
||||
* [Arch User Repository](https://aur.archlinux.org/packages/writefreely/)
|
||||
|
||||
## Documentation
|
||||
|
||||
Read our full [documentation on WriteFreely.org](https://writefreely.org/docs). Help us improve by contributing to the [writefreely/documentation](https://github.com/writefreely/documentation) repo.
|
||||
Read our full [documentation on WriteFreely.org](https://writefreely.org/docs) —️ and help us improve by contributing to the [writefreely/documentation](https://github.com/writefreely/documentation) repo.
|
||||
|
||||
## Development
|
||||
|
||||
Ready to hack on your site? Get started with our [developer guide](https://writefreely.org/docs/latest/developer/setup).
|
||||
|
||||
## Docker
|
||||
|
||||
Read about using Docker in the [documentation](https://writefreely.org/docs/latest/admin/docker).
|
||||
Start hacking on WriteFreely with our [developer setup guide](https://writefreely.org/docs/latest/developer/setup). For Docker support, see our [Docker guide](https://writefreely.org/docs/latest/admin/docker).
|
||||
|
||||
## Contributing
|
||||
|
||||
|
@ -91,4 +86,4 @@ Before contributing anything, please read our [Contributing Guide](https://githu
|
|||
|
||||
## License
|
||||
|
||||
Licensed under the AGPL.
|
||||
Copyright © 2018-2020 [A Bunch Tell LLC](https://abunchtell.com) and contributing authors. Licensed under the [AGPL](https://github.com/writeas/writefreely/blob/develop/LICENSE).
|
||||
|
|
95
account.go
95
account.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.
|
||||
*
|
||||
|
@ -27,6 +27,7 @@ import (
|
|||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/data"
|
||||
"github.com/writeas/web-core/log"
|
||||
|
||||
"github.com/writeas/writefreely/author"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/writefreely/page"
|
||||
|
@ -70,7 +71,7 @@ func canUserInvite(cfg *config.Config, isAdmin bool) bool {
|
|||
}
|
||||
|
||||
func (up *UserPage) SetMessaging(u *User) {
|
||||
//up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID)
|
||||
// up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID)
|
||||
}
|
||||
|
||||
const (
|
||||
|
@ -167,11 +168,7 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
|
|||
|
||||
// Log invite if needed
|
||||
if signup.InviteCode != "" {
|
||||
cu, err := app.db.GetUserForAuth(signup.Alias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = app.db.CreateInvitedUser(signup.InviteCode, cu.ID)
|
||||
err = app.db.CreateInvitedUser(signup.InviteCode, u.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -302,12 +299,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 +315,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 != "" {
|
||||
|
@ -488,6 +489,9 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."}
|
||||
}
|
||||
}
|
||||
if len(u.HashedPass) == 0 {
|
||||
return impart.HTTPError{http.StatusUnauthorized, "This user never set a password. Perhaps try logging in via OAuth?"}
|
||||
}
|
||||
if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) {
|
||||
return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
|
||||
}
|
||||
|
@ -1038,18 +1042,52 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
|||
|
||||
flashes, _ := getSessionFlashes(app, w, r, nil)
|
||||
|
||||
enableOauthSlack := app.Config().SlackOauth.ClientID != ""
|
||||
enableOauthWriteAs := app.Config().WriteAsOauth.ClientID != ""
|
||||
enableOauthGitLab := app.Config().GitlabOauth.ClientID != ""
|
||||
|
||||
oauthAccounts, err := app.db.GetOauthAccounts(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
log.Error("Unable to get oauth accounts for settings: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
|
||||
}
|
||||
for _, oauthAccount := range oauthAccounts {
|
||||
switch oauthAccount.Provider {
|
||||
case "slack":
|
||||
enableOauthSlack = false
|
||||
case "write.as":
|
||||
enableOauthWriteAs = false
|
||||
case "gitlab":
|
||||
enableOauthGitLab = false
|
||||
}
|
||||
}
|
||||
|
||||
displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || len(oauthAccounts) > 0
|
||||
|
||||
obj := struct {
|
||||
*UserPage
|
||||
Email string
|
||||
HasPass bool
|
||||
IsLogOut bool
|
||||
Silenced bool
|
||||
Email string
|
||||
HasPass bool
|
||||
IsLogOut bool
|
||||
Silenced bool
|
||||
OauthSection bool
|
||||
OauthAccounts []oauthAccountInfo
|
||||
OauthSlack bool
|
||||
OauthWriteAs bool
|
||||
OauthGitLab bool
|
||||
GitLabDisplayName string
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
|
||||
Email: fullUser.EmailClear(app.keys),
|
||||
HasPass: passIsSet,
|
||||
IsLogOut: r.FormValue("logout") == "1",
|
||||
Silenced: fullUser.IsSilenced(),
|
||||
UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
|
||||
Email: fullUser.EmailClear(app.keys),
|
||||
HasPass: passIsSet,
|
||||
IsLogOut: r.FormValue("logout") == "1",
|
||||
Silenced: fullUser.IsSilenced(),
|
||||
OauthSection: displayOauthSection,
|
||||
OauthAccounts: oauthAccounts,
|
||||
OauthSlack: enableOauthSlack,
|
||||
OauthWriteAs: enableOauthWriteAs,
|
||||
OauthGitLab: enableOauthGitLab,
|
||||
GitLabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName),
|
||||
}
|
||||
|
||||
showUserPage(w, "settings", obj)
|
||||
|
@ -1094,6 +1132,19 @@ func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) s
|
|||
return s
|
||||
}
|
||||
|
||||
func removeOauth(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
provider := r.FormValue("provider")
|
||||
clientID := r.FormValue("client_id")
|
||||
remoteUserID := r.FormValue("remote_user_id")
|
||||
|
||||
err := app.db.RemoveOauth(r.Context(), u.ID, provider, clientID, remoteUserID)
|
||||
if err != nil {
|
||||
return impart.HTTPError{Status: http.StatusInternalServerError, Message: err.Error()}
|
||||
}
|
||||
|
||||
return impart.HTTPError{Status: http.StatusFound, Message: "/me/settings"}
|
||||
}
|
||||
|
||||
func prepareUserEmail(input string, emailKey []byte) zero.String {
|
||||
email := zero.NewString("", input != "")
|
||||
if len(input) > 0 {
|
||||
|
|
|
@ -160,6 +160,7 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
|
|||
pp.Collection = res
|
||||
o := pp.ActivityObject(app)
|
||||
a := activitystreams.NewCreateActivity(o)
|
||||
a.Context = nil
|
||||
ocp.OrderedItems = append(ocp.OrderedItems, *a)
|
||||
}
|
||||
|
||||
|
@ -396,7 +397,9 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
|
||||
go func() {
|
||||
if to == nil {
|
||||
log.Error("No to! %v", err)
|
||||
if debugging {
|
||||
log.Error("No `to` value!")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -708,7 +711,8 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
|||
|
||||
func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
||||
u := RemoteUser{ActorID: actorID}
|
||||
err := app.db.QueryRow("SELECT id, inbox, shared_inbox, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &u.Handle)
|
||||
var handle sql.NullString
|
||||
err := app.db.QueryRow("SELECT id, inbox, shared_inbox, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &handle)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."}
|
||||
|
@ -717,6 +721,8 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
u.Handle = handle.String
|
||||
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
|
|
8
app.go
8
app.go
|
@ -56,7 +56,7 @@ var (
|
|||
debugging bool
|
||||
|
||||
// Software version can be set from git env using -ldflags
|
||||
softwareVer = "0.11.2"
|
||||
softwareVer = "0.12.0"
|
||||
|
||||
// DEPRECATED VARS
|
||||
isSingleUser bool
|
||||
|
@ -221,6 +221,10 @@ func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
return handleViewPad(app, w, r)
|
||||
}
|
||||
|
||||
if app.cfg.App.Private {
|
||||
return viewLogin(app, w, r)
|
||||
}
|
||||
|
||||
if land := app.cfg.App.LandingPath(); land != "/" {
|
||||
return impart.HTTPError{http.StatusFound, land}
|
||||
}
|
||||
|
@ -749,7 +753,7 @@ func connectToDatabase(app *App) {
|
|||
var db *sql.DB
|
||||
var err error
|
||||
if app.cfg.Database.Type == driverMySQL {
|
||||
db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String())))
|
||||
db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s&tls=%t", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String()), app.cfg.Database.TLS))
|
||||
db.SetMaxOpenConns(50)
|
||||
} else if app.cfg.Database.Type == driverSQLite {
|
||||
if !SQLiteEnabled {
|
||||
|
|
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
|
||||
}
|
|
@ -9,6 +9,7 @@ password = changeme
|
|||
database = writefreely
|
||||
host = db
|
||||
port = 3306
|
||||
tls = false
|
||||
|
||||
[app]
|
||||
site_name = WriteFreely Example Blog!
|
||||
|
|
|
@ -59,6 +59,7 @@ type (
|
|||
Database string `ini:"database"`
|
||||
Host string `ini:"host"`
|
||||
Port int `ini:"port"`
|
||||
TLS bool `ini:"tls"`
|
||||
}
|
||||
|
||||
WriteAsOauthCfg struct {
|
||||
|
@ -71,6 +72,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"`
|
||||
|
@ -130,6 +140,7 @@ type (
|
|||
App AppCfg `ini:"app"`
|
||||
SlackOauth SlackOauthCfg `ini:"oauth.slack"`
|
||||
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
|
||||
GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"`
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -185,6 +196,16 @@ func (ac *AppCfg) LandingPath() string {
|
|||
return ac.Landing
|
||||
}
|
||||
|
||||
func (ac AppCfg) SignupPath() string {
|
||||
if !ac.OpenRegistration {
|
||||
return ""
|
||||
}
|
||||
if ac.Chorus || ac.Private || (ac.Landing != "" && ac.Landing != "/") {
|
||||
return "/signup"
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// Load reads the given configuration file, then parses and returns it as a Config.
|
||||
func Load(fname string) (*Config, error) {
|
||||
if fname == "" {
|
||||
|
|
|
@ -356,7 +356,7 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
|||
if data.Config.App.Federation {
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Federation usage stats",
|
||||
Label: "Usage stats (active users, posts)",
|
||||
Items: []string{"Public", "Private"},
|
||||
}
|
||||
_, fedStatsType, err := selPrompt.Run()
|
||||
|
|
|
@ -22,3 +22,7 @@ func (db *datastore) isDuplicateKeyErr(err error) bool {
|
|||
func (db *datastore) isIgnorableError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isHighLoadError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -40,3 +40,13 @@ func (db *datastore) isIgnorableError(err error) bool {
|
|||
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isHighLoadError(err error) bool {
|
||||
if db.driverName == driverMySQL {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
return mysqlErr.Number == mySQLErrMaxUserConns || mysqlErr.Number == mySQLErrTooManyConns
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// +build sqlite,!wflib
|
||||
|
||||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -60,3 +60,13 @@ func (db *datastore) isIgnorableError(err error) bool {
|
|||
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isHighLoadError(err error) bool {
|
||||
if db.driverName == driverMySQL {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
return mysqlErr.Number == mySQLErrMaxUserConns || mysqlErr.Number == mySQLErrTooManyConns
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
70
database.go
70
database.go
|
@ -39,6 +39,8 @@ import (
|
|||
const (
|
||||
mySQLErrDuplicateKey = 1062
|
||||
mySQLErrCollationMix = 1267
|
||||
mySQLErrTooManyConns = 1040
|
||||
mySQLErrMaxUserConns = 1203
|
||||
|
||||
driverMySQL = "mysql"
|
||||
driverSQLite = "sqlite3"
|
||||
|
@ -130,8 +132,10 @@ type writestore interface {
|
|||
|
||||
GetIDForRemoteUser(context.Context, string, string, string) (int64, error)
|
||||
RecordRemoteUserID(context.Context, int64, string, string, string, string) error
|
||||
ValidateOAuthState(context.Context, string) (string, string, error)
|
||||
GenerateOAuthState(context.Context, string, string) (string, error)
|
||||
ValidateOAuthState(context.Context, string) (string, string, int64, string, error)
|
||||
GenerateOAuthState(context.Context, string, string, int64, string) (string, error)
|
||||
GetOauthAccounts(ctx context.Context, userID int64) ([]oauthAccountInfo, error)
|
||||
RemoveOauth(ctx context.Context, userID int64, provider string, clientID string, remoteUserID string) error
|
||||
|
||||
DatabaseInitialized() bool
|
||||
}
|
||||
|
@ -174,6 +178,7 @@ func (db *datastore) dateSub(l int, unit string) string {
|
|||
return fmt.Sprintf("DATE_SUB(NOW(), INTERVAL %d %s)", l, unit)
|
||||
}
|
||||
|
||||
// CreateUser creates a new user in the database from the given User, UPDATING it in the process with the user's ID.
|
||||
func (db *datastore) CreateUser(cfg *config.Config, u *User, collectionTitle string) error {
|
||||
if db.PostIDExists(u.Username) {
|
||||
return impart.HTTPError{http.StatusConflict, "Invalid collection name."}
|
||||
|
@ -793,6 +798,8 @@ func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Coll
|
|||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
return nil, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
|
||||
case db.isHighLoadError(err):
|
||||
return nil, ErrUnavailable
|
||||
case err != nil:
|
||||
log.Error("Failed selecting from collections: %v", err)
|
||||
return nil, err
|
||||
|
@ -2050,7 +2057,7 @@ func (db *datastore) RemoveCollectionRedirect(t *sql.Tx, alias string) error {
|
|||
func (db *datastore) GetCollectionRedirect(alias string) (new string) {
|
||||
row := db.QueryRow("SELECT new_alias FROM collectionredirects WHERE prev_alias = ?", alias)
|
||||
err := row.Scan(&new)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
if err != nil && err != sql.ErrNoRows && !db.isIgnorableError(err) {
|
||||
log.Error("Failed selecting from collectionredirects: %v", err)
|
||||
}
|
||||
return
|
||||
|
@ -2544,20 +2551,26 @@ func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) {
|
|||
return &t, nil
|
||||
}
|
||||
|
||||
func (db *datastore) GenerateOAuthState(ctx context.Context, provider, clientID string) (string, error) {
|
||||
func (db *datastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUser int64, inviteCode string) (string, error) {
|
||||
state := store.Generate62RandomString(24)
|
||||
_, err := db.ExecContext(ctx, "INSERT INTO oauth_client_states (state, provider, client_id, used, created_at) VALUES (?, ?, ?, FALSE, NOW())", state, provider, clientID)
|
||||
attachUserVal := sql.NullInt64{Valid: attachUser > 0, Int64: attachUser}
|
||||
inviteCodeVal := sql.NullString{Valid: inviteCode != "", String: inviteCode}
|
||||
_, err := db.ExecContext(ctx, "INSERT INTO oauth_client_states (state, provider, client_id, used, created_at, attach_user_id, invite_code) VALUES (?, ?, ?, FALSE, "+db.now()+", ?, ?)", state, provider, clientID, attachUserVal, inviteCodeVal)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to record oauth client state: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (db *datastore) ValidateOAuthState(ctx context.Context, state string) (string, string, error) {
|
||||
func (db *datastore) ValidateOAuthState(ctx context.Context, state string) (string, string, int64, string, error) {
|
||||
var provider string
|
||||
var clientID string
|
||||
var attachUserID sql.NullInt64
|
||||
var inviteCode sql.NullString
|
||||
err := wf_db.RunTransactionWithOptions(ctx, db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
|
||||
err := tx.QueryRow("SELECT provider, client_id FROM oauth_client_states WHERE state = ? AND used = FALSE", state).Scan(&provider, &clientID)
|
||||
err := tx.
|
||||
QueryRowContext(ctx, "SELECT provider, client_id, attach_user_id, invite_code FROM oauth_client_states WHERE state = ? AND used = FALSE", state).
|
||||
Scan(&provider, &clientID, &attachUserID, &inviteCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -2576,9 +2589,9 @@ func (db *datastore) ValidateOAuthState(ctx context.Context, state string) (stri
|
|||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", nil
|
||||
return "", "", 0, "", nil
|
||||
}
|
||||
return provider, clientID, nil
|
||||
return provider, clientID, attachUserID.Int64, inviteCode.String, nil
|
||||
}
|
||||
|
||||
func (db *datastore) RecordRemoteUserID(ctx context.Context, localUserID int64, remoteUserID, provider, clientID, accessToken string) error {
|
||||
|
@ -2607,6 +2620,33 @@ func (db *datastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provi
|
|||
return userID, nil
|
||||
}
|
||||
|
||||
type oauthAccountInfo struct {
|
||||
Provider string
|
||||
ClientID string
|
||||
RemoteUserID string
|
||||
}
|
||||
|
||||
func (db *datastore) GetOauthAccounts(ctx context.Context, userID int64) ([]oauthAccountInfo, error) {
|
||||
rows, err := db.QueryContext(ctx, "SELECT provider, client_id, remote_user_id FROM oauth_users WHERE user_id = ? ", userID)
|
||||
if err != nil {
|
||||
log.Error("Failed selecting from oauth_users: %v", err)
|
||||
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user oauth accounts."}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []oauthAccountInfo
|
||||
for rows.Next() {
|
||||
info := oauthAccountInfo{}
|
||||
err = rows.Scan(&info.Provider, &info.ClientID, &info.RemoteUserID)
|
||||
if err != nil {
|
||||
log.Error("Failed scanning GetAllUsers() row: %v", err)
|
||||
break
|
||||
}
|
||||
records = append(records, info)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// DatabaseInitialized returns whether or not the current datastore has been
|
||||
// initialized with the correct schema.
|
||||
// Currently, it checks to see if the `users` table exists.
|
||||
|
@ -2629,6 +2669,11 @@ func (db *datastore) DatabaseInitialized() bool {
|
|||
return true
|
||||
}
|
||||
|
||||
func (db *datastore) RemoveOauth(ctx context.Context, userID int64, provider string, clientID string, remoteUserID string) error {
|
||||
_, err := db.ExecContext(ctx, `DELETE FROM oauth_users WHERE user_id = ? AND provider = ? AND client_id = ? AND remote_user_id = ?`, userID, provider, clientID, remoteUserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func stringLogln(log *string, s string, v ...interface{}) {
|
||||
*log += fmt.Sprintf(s+"\n", v...)
|
||||
}
|
||||
|
@ -2639,6 +2684,7 @@ func handleFailedPostInsert(err error) error {
|
|||
}
|
||||
|
||||
func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, error) {
|
||||
handle = strings.TrimLeft(handle, "@")
|
||||
actorIRI := ""
|
||||
remoteUser, err := getRemoteUserFromHandle(app, handle)
|
||||
if err != nil {
|
||||
|
@ -2651,21 +2697,21 @@ func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string,
|
|||
if errRemoteUser == nil {
|
||||
_, err := app.db.Exec("UPDATE remoteusers SET handle = ? WHERE actor_id = ?", handle, actorIRI)
|
||||
if err != nil {
|
||||
log.Error("Can't update handle (" + handle + ") in database for user " + actorIRI)
|
||||
log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI)
|
||||
}
|
||||
} else {
|
||||
// this probably means we don't have the user in the table so let's try to insert it
|
||||
// here we need to ask the server for the inboxes
|
||||
remoteActor, err := activityserve.NewRemoteActor(actorIRI)
|
||||
if err != nil {
|
||||
log.Error("Couldn't fetch remote actor", err)
|
||||
log.Error("Couldn't fetch remote actor: %v", err)
|
||||
}
|
||||
if debugging {
|
||||
log.Info("%s %s %s %s", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), handle)
|
||||
}
|
||||
_, err = app.db.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, handle) VALUES(?, ?, ?, ?)", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), handle)
|
||||
if err != nil {
|
||||
log.Error("Can't insert remote user in database", err)
|
||||
log.Error("Couldn't insert remote user: %v", err)
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,13 +18,13 @@ func TestOAuthDatastore(t *testing.T) {
|
|||
driverName: "",
|
||||
}
|
||||
|
||||
state, err := ds.GenerateOAuthState(ctx, "test", "development")
|
||||
state, err := ds.GenerateOAuthState(ctx, "test", "development", 0, "")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, state, 24)
|
||||
|
||||
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = false", state)
|
||||
|
||||
_, _, err = ds.ValidateOAuthState(ctx, state)
|
||||
_, _, _, _, err = ds.ValidateOAuthState(ctx, state)
|
||||
assert.NoError(t, err)
|
||||
|
||||
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = true", state)
|
||||
|
|
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
|
||||
}
|
||||
|
||||
|
|
|
@ -37,6 +37,8 @@ var (
|
|||
ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."}
|
||||
ErrInternalCookieSession = impart.HTTPError{http.StatusInternalServerError, "Could not get cookie session."}
|
||||
|
||||
ErrUnavailable = impart.HTTPError{http.StatusServiceUnavailable, "Service temporarily unavailable due to high load."}
|
||||
|
||||
ErrCollectionNotFound = impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
|
||||
ErrCollectionGone = impart.HTTPError{http.StatusGone, "This blog was unpublished."}
|
||||
ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."}
|
||||
|
|
20
go.mod
20
go.mod
|
@ -1,27 +1,23 @@
|
|||
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
|
||||
github.com/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
github.com/fatih/color v1.7.0
|
||||
github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d // indirect
|
||||
github.com/go-sql-driver/mysql v1.4.1
|
||||
github.com/go-test/deep v1.0.1 // indirect
|
||||
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
|
||||
github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8 // indirect
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
|
||||
github.com/gorilla/feeds v1.1.0
|
||||
github.com/gorilla/mux v1.7.0
|
||||
github.com/gorilla/mux v1.7.4
|
||||
github.com/gorilla/schema v1.0.2
|
||||
github.com/gorilla/sessions v1.2.0
|
||||
github.com/guregu/null v3.4.0+incompatible
|
||||
github.com/hashicorp/go-multierror v1.0.0
|
||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
|
||||
github.com/jteeuwen/go-bindata v3.0.7+incompatible // indirect
|
||||
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
|
||||
github.com/lunixbochs/vtclean v1.0.0 // indirect
|
||||
|
@ -39,16 +35,17 @@ 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.6.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/activityserve v0.0.0-20200409150223-d7ab3eaa4481
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible
|
||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2
|
||||
github.com/writeas/go-webfinger v1.1.0
|
||||
github.com/writeas/httpsig v1.0.0
|
||||
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d
|
||||
github.com/writeas/impart v1.1.1
|
||||
github.com/writeas/import v0.2.0
|
||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219
|
||||
github.com/writeas/nerds v1.0.0
|
||||
github.com/writeas/saturday v1.7.1
|
||||
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320
|
||||
github.com/writeas/slug v1.2.0
|
||||
github.com/writeas/web-core v1.2.0
|
||||
github.com/writefreely/go-nodeinfo v1.2.0
|
||||
|
@ -58,8 +55,7 @@ require (
|
|||
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 // indirect
|
||||
google.golang.org/appengine v1.4.0 // indirect
|
||||
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect
|
||||
gopkg.in/ini.v1 v1.41.0
|
||||
gopkg.in/yaml.v2 v2.2.2 // indirect
|
||||
gopkg.in/ini.v1 v1.55.0
|
||||
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b // indirect
|
||||
)
|
||||
|
||||
|
|
51
go.sum
51
go.sum
|
@ -9,8 +9,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZq
|
|||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
|
||||
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 h1:jWNY1NDg6a/c8RSXkai7IX6UOhir0LD39I4Dukg+4Ks=
|
||||
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
|
||||
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 h1:AFSJaASPGYNbkUa5c8ZybrcW9pP3Cy7+z5dnpcc/qG8=
|
||||
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
|
||||
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
|
||||
|
@ -22,11 +22,13 @@ github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
|||
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 h1:74lLNRzvsdIlkTgfDSMuaPjBr4cf6k7pwQQANm/yLKU=
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
|
||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs=
|
||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
|
||||
|
@ -35,8 +37,8 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
|||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/go-fed/httpsig v0.1.0 h1:6F2OxRVnNTN4OPN+Mc2jxs2WEay9/qiHT/jphlvAwIY=
|
||||
github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
||||
github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d h1:+uoOvOnNDgsYbWtAij4xP6Rgir3eJGjocFPxBJETU/U=
|
||||
github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
||||
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe h1:U71giCx5NjRn4Lb71UuprPHqhjxGv3Jqonb9fgcaJH8=
|
||||
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
||||
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
|
||||
|
@ -45,8 +47,8 @@ github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:tluoj9z5200j
|
|||
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 h1:6DVPu65tee05kY0/rciBQ47ue+AnuY8KTayV6VHikIo=
|
||||
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8 h1:WD8iJ37bRNwvETMfVTusVSAi0WdXTpfNVGY2aHycNKY=
|
||||
github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U=
|
||||
github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c=
|
||||
github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U=
|
||||
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf h1:7+FW5aGwISbqUtkfmIpZJGRgNFg2ioYPvFaUxdqpDsg=
|
||||
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
|
||||
|
@ -55,8 +57,8 @@ github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc h1:cJlkeAx
|
|||
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU=
|
||||
github.com/gorilla/feeds v1.1.0 h1:pcgLJhbdYgaUESnj3AmXPcB7cS3vy63+jC/TI14AGXk=
|
||||
github.com/gorilla/feeds v1.1.0/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
||||
github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U=
|
||||
github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
|
||||
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/schema v1.0.2 h1:sAgNfOcNYvdDSrzGHVy9nzCQahG+qmsg+nE8dK85QRA=
|
||||
github.com/gorilla/schema v1.0.2/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
|
@ -71,6 +73,8 @@ github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uP
|
|||
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM=
|
||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw=
|
||||
github.com/jteeuwen/go-bindata v3.0.7+incompatible h1:91Uy4d9SYVr1kyTJ15wJsog+esAZZl7JmEfTkwmhJts=
|
||||
github.com/jteeuwen/go-bindata v3.0.7+incompatible/go.mod h1:JVvhzYOiGBnFSYRyV00iY8q7/0PThjIYav1p9h5dmKs=
|
||||
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
|
||||
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU=
|
||||
|
@ -116,6 +120,8 @@ github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469 h1:rAbv2gekFbUc
|
|||
github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469/go.mod h1:c61IFFAJw8ADWu54tti30Tj5VrBstVoTprmET35UEkY=
|
||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
|
||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
|
||||
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY=
|
||||
|
@ -129,18 +135,16 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
|||
github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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/activityserve v0.0.0-20200409150223-d7ab3eaa4481 h1:BiSivIxLQFcKoUorpNN3rNwwFG5bITPnqUSyIccfdh0=
|
||||
github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o=
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
|
||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2 h1:DUsp4OhdfI+e6iUqcPQlwx8QYXuUDsToTz/x82D3Zuo=
|
||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
|
||||
github.com/writeas/go-webfinger v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q=
|
||||
github.com/writeas/go-webfinger v1.1.0/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
|
||||
github.com/writeas/go-writeas v1.1.0 h1:WHGm6wriBkxYAOGbvriXH8DlMUGOi6jhSZLUZKQ+4mQ=
|
||||
github.com/writeas/go-writeas v1.1.0/go.mod h1:oh9U1rWaiE0p3kzdKwwvOpNXgp0P0IELI7OLOwV4fkA=
|
||||
github.com/writeas/go-writeas/v2 v2.0.2 h1:akvdMg89U5oBJiCkBwOXljVLTqP354uN6qnG2oOMrbk=
|
||||
|
@ -149,8 +153,8 @@ github.com/writeas/httpsig v1.0.0 h1:peIAoIA3DmlP8IG8tMNZqI4YD1uEnWBmkcC9OFPjt3A
|
|||
github.com/writeas/httpsig v1.0.0/go.mod h1:7ClMGSrSVXJbmiLa17bZ1LrG1oibGZmUMlh3402flPY=
|
||||
github.com/writeas/impart v1.1.0 h1:nPnoO211VscNkp/gnzir5UwCDEvdHThL5uELU60NFSE=
|
||||
github.com/writeas/impart v1.1.0/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
||||
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d h1:PK7DOj3JE6MGf647esPrKzXEHFjGWX2hl22uX79ixaE=
|
||||
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
||||
github.com/writeas/impart v1.1.1 h1:RyA9+CqbdbDuz53k+nXCWUY+NlEkdyw6+nWanxSBl5o=
|
||||
github.com/writeas/impart v1.1.1/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
||||
github.com/writeas/import v0.2.0 h1:Ov23JW9Rnjxk06rki1Spar45bNX647HhwhAZj3flJiY=
|
||||
github.com/writeas/import v0.2.0/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM=
|
||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 h1:baEp0631C8sT2r/hqwypIw2snCFZa6h7U6TojoLHu/c=
|
||||
|
@ -162,6 +166,8 @@ github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD2
|
|||
github.com/writeas/saturday v1.6.0/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||
github.com/writeas/saturday v1.7.1 h1:lYo1EH6CYyrFObQoA9RNWHVlpZA5iYL5Opxo7PYAnZE=
|
||||
github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 h1:PozPZ29CQ/xt6ym/+FvIz+KvKEObSSc5ye+95zbTjVU=
|
||||
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||
github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g=
|
||||
github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ=
|
||||
github.com/writeas/web-core v1.2.0 h1:CYqvBd+byi1cK4mCr1NZ6CjILuMOFmiFecv+OACcmG0=
|
||||
|
@ -205,12 +211,11 @@ gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c/go.mo
|
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.41.0 h1:Ka3ViY6gNYSKiVy71zXBEqKplnV35ImDLVG+8uoIklE=
|
||||
gopkg.in/ini.v1 v1.41.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.55.0 h1:E8yzL5unfpW3M6fz/eB7Cb5MQAYSZ7GKo4Qth+N2sgQ=
|
||||
gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b h1:rPAdjgXks4ToezTjygsnKZroxKVnA1L35DSpsJXPtfc=
|
||||
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU=
|
||||
|
|
|
@ -85,6 +85,7 @@ type ErrorPages struct {
|
|||
NotFound *template.Template
|
||||
Gone *template.Template
|
||||
InternalServerError *template.Template
|
||||
UnavailableError *template.Template
|
||||
Blank *template.Template
|
||||
}
|
||||
|
||||
|
@ -96,6 +97,7 @@ func NewHandler(apper Apper) *Handler {
|
|||
NotFound: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>404</title></head><body><p>Not found.</p></body></html>{{end}}")),
|
||||
Gone: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>410</title></head><body><p>Gone.</p></body></html>{{end}}")),
|
||||
InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>500</title></head><body><p>Internal server error.</p></body></html>{{end}}")),
|
||||
UnavailableError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>503</title></head><body><p>Service is temporarily unavailable.</p></body></html>{{end}}")),
|
||||
Blank: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>{{.Title}}</title></head><body><p>{{.Content}}</p></body></html>{{end}}")),
|
||||
},
|
||||
sessionStore: apper.App().SessionStore(),
|
||||
|
@ -113,6 +115,7 @@ func NewWFHandler(apper Apper) *Handler {
|
|||
NotFound: pages["404-general.tmpl"],
|
||||
Gone: pages["410.tmpl"],
|
||||
InternalServerError: pages["500.tmpl"],
|
||||
UnavailableError: pages["503.tmpl"],
|
||||
Blank: pages["blank.tmpl"],
|
||||
})
|
||||
return h
|
||||
|
@ -765,6 +768,10 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
|
|||
log.Info("handleHTTPErorr internal error render")
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
return
|
||||
} else if err.Status == http.StatusServiceUnavailable {
|
||||
w.WriteHeader(err.Status)
|
||||
h.errors.UnavailableError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
return
|
||||
} else if err.Status == http.StatusAccepted {
|
||||
impart.WriteSuccess(w, "", err.Status)
|
||||
return
|
||||
|
|
19
invites.go
19
invites.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -42,6 +42,18 @@ func (i Invite) Expired() bool {
|
|||
return i.Expires != nil && i.Expires.Before(time.Now())
|
||||
}
|
||||
|
||||
func (i Invite) Active(db *datastore) bool {
|
||||
if i.Expired() {
|
||||
return false
|
||||
}
|
||||
if i.MaxUses.Valid && i.MaxUses.Int64 > 0 {
|
||||
if c := db.GetUsersInvitedCount(i.ID); c >= i.MaxUses.Int64 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (i Invite) ExpiresFriendly() string {
|
||||
return i.Expires.Format("January 2, 2006, 3:04 PM")
|
||||
}
|
||||
|
@ -161,15 +173,20 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
Error string
|
||||
Flashes []template.HTML
|
||||
Invite string
|
||||
OAuth *OAuthButtons
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
Invite: inviteCode,
|
||||
OAuth: NewOAuthButtons(app.cfg),
|
||||
}
|
||||
|
||||
if expired {
|
||||
p.Error = "This invite link has expired."
|
||||
}
|
||||
|
||||
// Tell search engines not to index invite links
|
||||
w.Header().Set("X-Robots-Tag", "noindex")
|
||||
|
||||
// Get error messages
|
||||
session, err := app.sessionStore.Get(r, cookieName)
|
||||
if err != nil {
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
@import "post-temp";
|
||||
@import "effects";
|
||||
@import "admin";
|
||||
@import "login";
|
||||
@import "pages/error";
|
||||
@import "lib/elements";
|
||||
@import "lib/material";
|
||||
|
|
|
@ -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;
|
||||
|
@ -810,7 +827,7 @@ input {
|
|||
font-weight: normal;
|
||||
}
|
||||
p {
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
}
|
||||
li {
|
||||
margin: 0.3em 0;
|
||||
|
@ -990,7 +1007,7 @@ footer.contain-me {
|
|||
}
|
||||
|
||||
li {
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
|
||||
.item-desc, .prog-lang {
|
||||
font-size: 0.6em;
|
||||
|
|
45
less/login.less
Normal file
45
less/login.less
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright © 2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
.row.signinbtns {
|
||||
justify-content: space-evenly;
|
||||
font-size: 1em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
|
||||
.loginbtn {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
#writeas-login, #gitlab-login {
|
||||
box-sizing: border-box;
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.or {
|
||||
text-align: center;
|
||||
margin-bottom: 3.5em;
|
||||
|
||||
p {
|
||||
display: inline-block;
|
||||
background-color: white;
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-top: -1.6em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
hr.short {
|
||||
max-width: 30rem;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
@actionNavColor: #999;
|
||||
@actionNavColor: #767676;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
|
@ -58,7 +58,7 @@ header {
|
|||
}
|
||||
p {
|
||||
&.description {
|
||||
color: #666;
|
||||
color: #444;
|
||||
font-size: 1.1em;
|
||||
margin-top: 0.5em;
|
||||
line-height: 1.5;
|
||||
|
@ -113,7 +113,7 @@ textarea {
|
|||
ul {
|
||||
margin: 0;
|
||||
padding: 0 0 0 1em;
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
|
||||
&.collections, &.posts, &.integrations {
|
||||
list-style: none;
|
||||
|
@ -206,7 +206,7 @@ code, textarea#embed {
|
|||
font-weight: normal;
|
||||
}
|
||||
p {
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
}
|
||||
li {
|
||||
margin: 0.3em 0;
|
||||
|
|
|
@ -361,6 +361,24 @@ body#pad {
|
|||
z-index: 10;
|
||||
}
|
||||
|
||||
body#pad .alert {
|
||||
position: fixed;
|
||||
bottom: 0.25em;
|
||||
left: 2em;
|
||||
right: 2em;
|
||||
font-size: 1.1em;
|
||||
|
||||
&#edited-elsewhere {
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-height: 500px) {
|
||||
body#pad {
|
||||
textarea {
|
||||
|
@ -425,6 +443,10 @@ body#pad {
|
|||
padding-left: 10%;
|
||||
padding-right: 10%;
|
||||
}
|
||||
.alert {
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 60em) {
|
||||
|
@ -433,6 +455,10 @@ body#pad {
|
|||
padding-left: 15%;
|
||||
padding-right: 15%;
|
||||
}
|
||||
.alert {
|
||||
left: 15%;
|
||||
right: 15%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 70em) {
|
||||
|
@ -441,6 +467,10 @@ body#pad {
|
|||
padding-left: 20%;
|
||||
padding-right: 20%;
|
||||
}
|
||||
.alert {
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 85em) {
|
||||
|
@ -449,6 +479,10 @@ body#pad {
|
|||
padding-left: 25%;
|
||||
padding-right: 25%;
|
||||
}
|
||||
.alert {
|
||||
left: 25%;
|
||||
right: 25%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 105em) {
|
||||
|
@ -457,6 +491,10 @@ body#pad {
|
|||
padding-left: 30%;
|
||||
padding-right: 30%;
|
||||
}
|
||||
.alert {
|
||||
left: 30%;
|
||||
right: 30%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
|
|
|
@ -49,7 +49,7 @@ body#post article, pre, .hljs {
|
|||
border-left: 4px solid #ddd;
|
||||
padding: 0 1em;
|
||||
margin: 0.5em;
|
||||
color: #777;
|
||||
color: #767676;
|
||||
display: inline-block;
|
||||
|
||||
p {
|
||||
|
@ -58,7 +58,7 @@ body#post article, pre, .hljs {
|
|||
}
|
||||
}
|
||||
.article-p() {
|
||||
line-height: 1.4em;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap; /* CSS 3 */
|
||||
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
||||
white-space: -pre-wrap; /* Opera 4-6 */
|
||||
|
|
|
@ -61,7 +61,10 @@ var migrations = []Migration{
|
|||
New("support users suspension", supportUserStatus), // V2 -> V3 (v0.11.0)
|
||||
New("support oauth", oauth), // V3 -> V4
|
||||
New("support slack oauth", oauthSlack), // V4 -> v5
|
||||
New("support ActivityPub mentions", supportActivityPubMentions), // V5 -> V6 (v0.12.0)
|
||||
New("support ActivityPub mentions", supportActivityPubMentions), // V5 -> V6
|
||||
New("support oauth attach", oauthAttach), // V6 -> V7
|
||||
New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0)
|
||||
New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9
|
||||
}
|
||||
|
||||
// CurrentVer returns the current migration version the application is on
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -13,7 +13,7 @@ package migrations
|
|||
func supportActivityPubMentions(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
|
||||
_, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN handle ` + db.typeVarChar(255) + ` DEFAULT '' NOT NULL`)
|
||||
_, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN handle ` + db.typeVarChar(255) + ` NULL`)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
|
|
36
migrations/v7.go
Normal file
36
migrations/v7.go
Normal file
|
@ -0,0 +1,36 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
wf_db "github.com/writeas/writefreely/db"
|
||||
)
|
||||
|
||||
func oauthAttach(db *datastore) error {
|
||||
dialect := wf_db.DialectMySQL
|
||||
if db.driverName == driverSQLite {
|
||||
dialect = wf_db.DialectSQLite
|
||||
}
|
||||
return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
|
||||
builders := []wf_db.SQLBuilder{
|
||||
dialect.
|
||||
AlterTable("oauth_client_states").
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"attach_user_id",
|
||||
wf_db.ColumnTypeInteger,
|
||||
wf_db.OptionalInt{Set: true, Value: 24}).SetNullable(true)),
|
||||
}
|
||||
for _, builder := range builders {
|
||||
query, err := builder.ToSQL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, query); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
45
migrations/v8.go
Normal file
45
migrations/v8.go
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright © 2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
wf_db "github.com/writeas/writefreely/db"
|
||||
)
|
||||
|
||||
func oauthInvites(db *datastore) error {
|
||||
dialect := wf_db.DialectMySQL
|
||||
if db.driverName == driverSQLite {
|
||||
dialect = wf_db.DialectSQLite
|
||||
}
|
||||
return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
|
||||
builders := []wf_db.SQLBuilder{
|
||||
dialect.
|
||||
AlterTable("oauth_client_states").
|
||||
AddColumn(dialect.Column("invite_code", wf_db.ColumnTypeChar, wf_db.OptionalInt{
|
||||
Set: true,
|
||||
Value: 6,
|
||||
}).SetNullable(true)),
|
||||
}
|
||||
for _, builder := range builders {
|
||||
query, err := builder.ToSQL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, query); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
37
migrations/v9.go
Normal file
37
migrations/v9.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright © 2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
func optimizeDrafts(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if db.driverName == driverSQLite {
|
||||
_, err = t.Exec(`CREATE INDEX key_owner_post_id ON posts (owner_id, id)`)
|
||||
} else {
|
||||
_, err = t.Exec(`ALTER TABLE posts ADD INDEX(owner_id, id)`)
|
||||
}
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
125
oauth.go
125
oauth.go
|
@ -1,22 +1,51 @@
|
|||
/*
|
||||
* 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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/config"
|
||||
)
|
||||
|
||||
// OAuthButtons holds display information for different OAuth providers we support.
|
||||
type OAuthButtons struct {
|
||||
SlackEnabled bool
|
||||
WriteAsEnabled bool
|
||||
GitLabEnabled bool
|
||||
GitLabDisplayName string
|
||||
}
|
||||
|
||||
// NewOAuthButtons creates a new OAuthButtons struct based on our app configuration.
|
||||
func NewOAuthButtons(cfg *config.Config) *OAuthButtons {
|
||||
return &OAuthButtons{
|
||||
SlackEnabled: cfg.SlackOauth.ClientID != "",
|
||||
WriteAsEnabled: cfg.WriteAsOauth.ClientID != "",
|
||||
GitLabEnabled: cfg.GitlabOauth.ClientID != "",
|
||||
GitLabDisplayName: config.OrDefaultString(cfg.GitlabOauth.DisplayName, gitlabDisplayName),
|
||||
}
|
||||
}
|
||||
|
||||
// TokenResponse contains data returned when a token is created either
|
||||
// through a code exchange or using a refresh token.
|
||||
type TokenResponse struct {
|
||||
|
@ -59,8 +88,8 @@ type OAuthDatastoreProvider interface {
|
|||
type OAuthDatastore interface {
|
||||
GetIDForRemoteUser(context.Context, string, string, string) (int64, error)
|
||||
RecordRemoteUserID(context.Context, int64, string, string, string, string) error
|
||||
ValidateOAuthState(context.Context, string) (string, string, error)
|
||||
GenerateOAuthState(context.Context, string, string) (string, error)
|
||||
ValidateOAuthState(context.Context, string) (string, string, int64, string, error)
|
||||
GenerateOAuthState(context.Context, string, string, int64, string) (string, error)
|
||||
|
||||
CreateUser(*config.Config, *User, string) error
|
||||
GetUserByID(int64) (*User, error)
|
||||
|
@ -96,19 +125,32 @@ type oauthHandler struct {
|
|||
|
||||
func (h oauthHandler) viewOauthInit(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
ctx := r.Context()
|
||||
state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID())
|
||||
|
||||
var attachUser int64
|
||||
if attach := r.URL.Query().Get("attach"); attach == "t" {
|
||||
user, _ := getUserAndSession(app, r)
|
||||
if user == nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, "cannot attach auth to user: user not found in session"}
|
||||
}
|
||||
attachUser = user.ID
|
||||
}
|
||||
|
||||
state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID(), attachUser, r.FormValue("invite_code"))
|
||||
if err != nil {
|
||||
log.Error("viewOauthInit error: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
|
||||
}
|
||||
|
||||
if h.callbackProxy != nil {
|
||||
if err := h.callbackProxy.register(ctx, state); err != nil {
|
||||
log.Error("viewOauthInit error: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "could not register state server"}
|
||||
}
|
||||
}
|
||||
|
||||
location, err := h.oauthClient.buildLoginURL(state)
|
||||
if err != nil {
|
||||
log.Error("viewOauthInit error: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
|
||||
}
|
||||
return impart.HTTPError{http.StatusTemporaryRedirect, location}
|
||||
|
@ -149,7 +191,7 @@ func configureWriteAsOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
|||
callbackLocation: app.Config().App.Host + "/oauth/callback/write.as",
|
||||
httpClient: config.DefaultHTTPClient(),
|
||||
}
|
||||
callbackLocation = app.Config().SlackOauth.CallbackProxy
|
||||
callbackLocation = app.Config().WriteAsOauth.CallbackProxy
|
||||
}
|
||||
|
||||
oauthClient := writeAsOauthClient{
|
||||
|
@ -165,6 +207,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(),
|
||||
|
@ -185,7 +255,7 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
code := r.FormValue("code")
|
||||
state := r.FormValue("state")
|
||||
|
||||
provider, clientID, err := h.DB.ValidateOAuthState(ctx, state)
|
||||
provider, clientID, attachUserID, inviteCode, err := h.DB.ValidateOAuthState(ctx, state)
|
||||
if err != nil {
|
||||
log.Error("Unable to ValidateOAuthState: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
|
@ -197,7 +267,7 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
|
||||
// Now that we have the access token, let's use it real quick to make sur
|
||||
// Now that we have the access token, let's use it real quick to make sure
|
||||
// it really really works.
|
||||
tokenInfo, err := h.oauthClient.inspectOauthAccessToken(ctx, tokenResponse.AccessToken)
|
||||
if err != nil {
|
||||
|
@ -211,7 +281,15 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
|
||||
if localUserID != -1 && attachUserID > 0 {
|
||||
if err = addSessionFlash(app, w, r, "This Slack account is already attached to another user.", nil); err != nil {
|
||||
return impart.HTTPError{Status: http.StatusInternalServerError, Message: err.Error()}
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, "/me/settings"}
|
||||
}
|
||||
|
||||
if localUserID != -1 {
|
||||
// Existing user, so log in now
|
||||
user, err := h.DB.GetUserByID(localUserID)
|
||||
if err != nil {
|
||||
log.Error("Unable to GetUserByID %d: %s", localUserID, err)
|
||||
|
@ -223,6 +301,30 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
}
|
||||
return nil
|
||||
}
|
||||
if attachUserID > 0 {
|
||||
log.Info("attaching to user %d", attachUserID)
|
||||
err = h.DB.RecordRemoteUserID(r.Context(), attachUserID, tokenInfo.UserID, provider, clientID, tokenResponse.AccessToken)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, "/me/settings"}
|
||||
}
|
||||
|
||||
// New user registration below.
|
||||
// First, verify that user is allowed to register
|
||||
if inviteCode != "" {
|
||||
// Verify invite code is valid
|
||||
i, err := app.db.GetUserInvite(inviteCode)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
if !i.Active(app.db) {
|
||||
return impart.HTTPError{http.StatusNotFound, "Invite link has expired."}
|
||||
}
|
||||
} else if !app.cfg.App.OpenRegistration {
|
||||
addSessionFlash(app, w, r, ErrUserNotFound.Error(), nil)
|
||||
return impart.HTTPError{http.StatusFound, "/login"}
|
||||
}
|
||||
|
||||
displayName := tokenInfo.DisplayName
|
||||
if len(displayName) == 0 {
|
||||
|
@ -237,6 +339,7 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
TokenRemoteUser: tokenInfo.UserID,
|
||||
Provider: provider,
|
||||
ClientID: clientID,
|
||||
InviteCode: inviteCode,
|
||||
}
|
||||
tp.TokenHash = tp.HashTokenParams(h.Config.Server.HashSeed)
|
||||
|
||||
|
|
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
|
||||
}
|
|
@ -38,6 +38,7 @@ type viewOauthSignupVars struct {
|
|||
Provider string
|
||||
ClientID string
|
||||
TokenHash string
|
||||
InviteCode string
|
||||
|
||||
LoginUsername string
|
||||
Alias string // TODO: rename this to match the data it represents: the collection title
|
||||
|
@ -57,6 +58,7 @@ const (
|
|||
oauthParamAlias = "alias"
|
||||
oauthParamEmail = "email"
|
||||
oauthParamPassword = "password"
|
||||
oauthParamInviteCode = "invite_code"
|
||||
)
|
||||
|
||||
type oauthSignupPageParams struct {
|
||||
|
@ -68,6 +70,7 @@ type oauthSignupPageParams struct {
|
|||
ClientID string
|
||||
Provider string
|
||||
TokenHash string
|
||||
InviteCode string
|
||||
}
|
||||
|
||||
func (p oauthSignupPageParams) HashTokenParams(key string) string {
|
||||
|
@ -92,6 +95,7 @@ func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.R
|
|||
TokenRemoteUser: r.FormValue(oauthParamTokenRemoteUserID),
|
||||
ClientID: r.FormValue(oauthParamClientID),
|
||||
Provider: r.FormValue(oauthParamProvider),
|
||||
InviteCode: r.FormValue(oauthParamInviteCode),
|
||||
}
|
||||
if tp.HashTokenParams(h.Config.Server.HashSeed) != r.FormValue(oauthParamHash) {
|
||||
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Request has been tampered with."}
|
||||
|
@ -128,6 +132,14 @@ func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.R
|
|||
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||
}
|
||||
|
||||
// Log invite if needed
|
||||
if tp.InviteCode != "" {
|
||||
err = app.db.CreateInvitedUser(tp.InviteCode, newUser.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = h.DB.RecordRemoteUserID(r.Context(), newUser.ID, r.FormValue(oauthParamTokenRemoteUserID), r.FormValue(oauthParamProvider), r.FormValue(oauthParamClientID), r.FormValue(oauthParamAccessToken))
|
||||
if err != nil {
|
||||
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||
|
@ -195,6 +207,7 @@ func (h oauthHandler) showOauthSignupPage(app *App, w http.ResponseWriter, r *ht
|
|||
Provider: tp.Provider,
|
||||
ClientID: tp.ClientID,
|
||||
TokenHash: tp.TokenHash,
|
||||
InviteCode: tp.InviteCode,
|
||||
|
||||
LoginUsername: username,
|
||||
Alias: collTitle,
|
||||
|
|
|
@ -13,8 +13,6 @@ package writefreely
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/writeas/nerds/store"
|
||||
"github.com/writeas/slug"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
@ -167,7 +165,7 @@ func (c slackOauthClient) inspectOauthAccessToken(ctx context.Context, accessTok
|
|||
func (resp slackUserIdentityResponse) InspectResponse() *InspectResponse {
|
||||
return &InspectResponse{
|
||||
UserID: resp.User.ID,
|
||||
Username: fmt.Sprintf("%s-%s", slug.Make(resp.User.Name), store.GenerateRandomString("0123456789bcdfghjklmnpqrstvwxyz", 5)),
|
||||
Username: slug.Make(resp.User.Name),
|
||||
DisplayName: resp.User.Name,
|
||||
Email: resp.User.Email,
|
||||
}
|
||||
|
|
|
@ -22,8 +22,8 @@ type MockOAuthDatastoreProvider struct {
|
|||
}
|
||||
|
||||
type MockOAuthDatastore struct {
|
||||
DoGenerateOAuthState func(context.Context, string, string) (string, error)
|
||||
DoValidateOAuthState func(context.Context, string) (string, string, error)
|
||||
DoGenerateOAuthState func(context.Context, string, string, int64, string) (string, error)
|
||||
DoValidateOAuthState func(context.Context, string) (string, string, int64, string, error)
|
||||
DoGetIDForRemoteUser func(context.Context, string, string, string) (int64, error)
|
||||
DoCreateUser func(*config.Config, *User, string) error
|
||||
DoRecordRemoteUserID func(context.Context, int64, string, string, string, string) error
|
||||
|
@ -86,11 +86,11 @@ func (m *MockOAuthDatastoreProvider) Config() *config.Config {
|
|||
return cfg
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) ValidateOAuthState(ctx context.Context, state string) (string, string, error) {
|
||||
func (m *MockOAuthDatastore) ValidateOAuthState(ctx context.Context, state string) (string, string, int64, string, error) {
|
||||
if m.DoValidateOAuthState != nil {
|
||||
return m.DoValidateOAuthState(ctx, state)
|
||||
}
|
||||
return "", "", nil
|
||||
return "", "", 0, "", nil
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provider, clientID string) (int64, error) {
|
||||
|
@ -119,15 +119,13 @@ func (m *MockOAuthDatastore) GetUserByID(userID int64) (*User, error) {
|
|||
if m.DoGetUserByID != nil {
|
||||
return m.DoGetUserByID(userID)
|
||||
}
|
||||
user := &User{
|
||||
|
||||
}
|
||||
user := &User{}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) GenerateOAuthState(ctx context.Context, provider string, clientID string) (string, error) {
|
||||
func (m *MockOAuthDatastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUserID int64, inviteCode string) (string, error) {
|
||||
if m.DoGenerateOAuthState != nil {
|
||||
return m.DoGenerateOAuthState(ctx, provider, clientID)
|
||||
return m.DoGenerateOAuthState(ctx, provider, clientID, attachUserID, inviteCode)
|
||||
}
|
||||
return store.Generate62RandomString(14), nil
|
||||
}
|
||||
|
@ -173,7 +171,7 @@ func TestViewOauthInit(t *testing.T) {
|
|||
app := &MockOAuthDatastoreProvider{
|
||||
DoDB: func() OAuthDatastore {
|
||||
return &MockOAuthDatastore{
|
||||
DoGenerateOAuthState: func(ctx context.Context, provider, clientID string) (string, error) {
|
||||
DoGenerateOAuthState: func(ctx context.Context, provider, clientID string, attachUserID int64, inviteCode string) (string, error) {
|
||||
return "", fmt.Errorf("pretend unable to write state error")
|
||||
},
|
||||
}
|
||||
|
|
7
pages/503.tmpl
Normal file
7
pages/503.tmpl
Normal file
|
@ -0,0 +1,7 @@
|
|||
{{define "head"}}<title>Temporarily Unavailable — {{.SiteMetaName}}</title>{{end}}
|
||||
{{define "content"}}
|
||||
<div class="error-page">
|
||||
<p class="msg">The words aren't coming to me. 🗅</p>
|
||||
<p>We couldn't serve this page due to high server load. This should only be temporary.</p>
|
||||
</div>
|
||||
{{end}}
|
|
@ -3,35 +3,6 @@
|
|||
<meta itemprop="description" content="Log in to {{.SiteName}}.">
|
||||
<style>
|
||||
input{margin-bottom:0.5em;}
|
||||
.or {
|
||||
text-align: center;
|
||||
margin-bottom: 3.5em;
|
||||
}
|
||||
.or p {
|
||||
display: inline-block;
|
||||
background-color: white;
|
||||
padding: 0 1em;
|
||||
}
|
||||
.or hr {
|
||||
margin-top: -1.6em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
hr.short {
|
||||
max-width: 30rem;
|
||||
}
|
||||
.row.signinbtns {
|
||||
justify-content: space-evenly;
|
||||
font-size: 1em;
|
||||
margin-top: 3em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.loginbtn {
|
||||
height: 40px;
|
||||
}
|
||||
#writeas-login {
|
||||
box-sizing: border-box;
|
||||
font-size: 17px;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
|
@ -42,7 +13,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 +21,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">
|
||||
|
@ -65,7 +39,7 @@ hr.short {
|
|||
<input type="submit" id="btn-login" value="Login" />
|
||||
</form>
|
||||
|
||||
{{if and (not .SingleUser) .OpenRegistration}}<p style="text-align:center;font-size:0.9em;margin:3em auto;max-width:26em;">{{if .Message}}{{.Message}}{{else}}<em>No account yet?</em> <a href="/">Sign up</a> to start a blog.{{end}}</p>{{end}}
|
||||
{{if and (not .SingleUser) .OpenRegistration}}<p style="text-align:center;font-size:0.9em;margin:3em auto;max-width:26em;">{{if .Message}}{{.Message}}{{else}}<em>No account yet?</em> <a href="{{.SignupPath}}">Sign up</a> to start a blog.{{end}}</p>{{end}}
|
||||
|
||||
<script type="text/javascript">
|
||||
function disableSubmit() {
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
{{define "head"}}<title>Log in — {{.SiteName}}</title>
|
||||
<meta name="description" content="Log in to {{.SiteName}}.">
|
||||
<meta itemprop="description" content="Log in to {{.SiteName}}.">
|
||||
{{define "head"}}<title>Finish Creating Account — {{.SiteName}}</title>
|
||||
<style>input{margin-bottom:0.5em;}</style>
|
||||
<style type="text/css">
|
||||
h2 {
|
||||
|
@ -58,7 +56,7 @@ form dd {
|
|||
{{end}}
|
||||
{{define "content"}}
|
||||
<div id="pricing" class="tight content-container">
|
||||
<h1>Log in to {{.SiteName}}</h1>
|
||||
<h1>Finish creating account</h1>
|
||||
|
||||
{{if .Flashes}}<ul class="errors">
|
||||
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
||||
|
@ -74,6 +72,7 @@ form dd {
|
|||
<input type="hidden" name="provider" value="{{ .Provider }}" />
|
||||
<input type="hidden" name="client_id" value="{{ .ClientID }}" />
|
||||
<input type="hidden" name="signature" value="{{ .TokenHash }}" />
|
||||
{{if .InviteCode}}<input type="hidden" name="invite_code" value="{{ .InviteCode }}" />{{end}}
|
||||
|
||||
<dl class="billing">
|
||||
<label>
|
||||
|
@ -96,7 +95,7 @@ form dd {
|
|||
</dd>
|
||||
</label>
|
||||
<dt>
|
||||
<input type="submit" id="btn-login" value="Login" />
|
||||
<input type="submit" id="btn-login" value="Next" />
|
||||
</dt>
|
||||
</dl>
|
||||
</form>
|
||||
|
@ -129,7 +128,7 @@ var $aliasSite = document.getElementById('alias-site');
|
|||
var aliasOK = true;
|
||||
var typingTimer;
|
||||
var doneTypingInterval = 750;
|
||||
var doneTyping = function() {
|
||||
var doneTyping = function(genID) {
|
||||
// Check on username
|
||||
var alias = $alias.el.value;
|
||||
if (alias != "") {
|
||||
|
@ -152,6 +151,11 @@ var doneTyping = function() {
|
|||
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)error(?!\S)/g, '');
|
||||
$aliasSite.innerHTML = '{{ if .Federation }}@<strong>' + data.data + '</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>' + data.data + '</strong>/{{ end }}';
|
||||
} else {
|
||||
if (genID === true) {
|
||||
$alias.el.value = alias + "-" + randStr(4);
|
||||
doneTyping();
|
||||
return;
|
||||
}
|
||||
aliasOK = false;
|
||||
$alias.setClass('error');
|
||||
$aliasSite.className = 'error';
|
||||
|
@ -169,6 +173,14 @@ $alias.on('keyup input', function() {
|
|||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(doneTyping, doneTypingInterval);
|
||||
});
|
||||
doneTyping();
|
||||
function randStr(len) {
|
||||
var res = '';
|
||||
var chars = '23456789bcdfghjklmnpqrstvwxyz';
|
||||
for (var i=0; i<len; i++) {
|
||||
res += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
doneTyping(true);
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
|
@ -70,6 +70,25 @@ form dd {
|
|||
</ul>{{end}}
|
||||
|
||||
<div id="billing">
|
||||
{{ if or .OAuth.SlackEnabled .OAuth.WriteAsEnabled .OAuth.GitLabEnabled }}
|
||||
<div class="row content-container signinbtns">
|
||||
{{ if .OAuth.SlackEnabled }}
|
||||
<a class="loginbtn" href="/oauth/slack{{if .Invite}}?invite_code={{.Invite}}{{end}}"><img alt="Sign in with Slack" height="40" width="172" src="/img/sign_in_with_slack.png" srcset="/img/sign_in_with_slack.png 1x, /img/sign_in_with_slack@2x.png 2x" /></a>
|
||||
{{ end }}
|
||||
{{ if .OAuth.WriteAsEnabled }}
|
||||
<a class="btn cta loginbtn" id="writeas-login" href="/oauth/write.as{{if .Invite}}?invite_code={{.Invite}}{{end}}">Sign in with <strong>Write.as</strong></a>
|
||||
{{ end }}
|
||||
{{ if .OAuth.GitLabEnabled }}
|
||||
<a class="btn cta loginbtn" id="gitlab-login" href="/oauth/gitlab{{if .Invite}}?invite_code={{.Invite}}{{end}}">Sign in with <strong>{{.OAuth.GitLabDisplayName}}</strong></a>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="or">
|
||||
<p>or</p>
|
||||
<hr class="short" />
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<form action="/auth/signup" method="POST" id="signup-form" onsubmit="return signup()">
|
||||
<input type="hidden" name="invite_code" value="{{.Invite}}" />
|
||||
<dl class="billing">
|
||||
|
|
|
@ -179,6 +179,7 @@ func getSanitizationPolicy() *bluemonday.Policy {
|
|||
policy.AllowAttrs("target").OnElements("a")
|
||||
policy.AllowAttrs("title").OnElements("abbr")
|
||||
policy.AllowAttrs("style", "class", "id").Globally()
|
||||
policy.AllowElements("header", "footer")
|
||||
policy.AllowURLSchemes("http", "https", "mailto", "xmpp")
|
||||
return policy
|
||||
}
|
||||
|
|
28
posts.go
28
posts.go
|
@ -135,6 +135,7 @@ type (
|
|||
Views int64
|
||||
Font string
|
||||
Created time.Time
|
||||
Updated time.Time
|
||||
IsRTL sql.NullBool
|
||||
Language sql.NullString
|
||||
OwnerID int64
|
||||
|
@ -1175,14 +1176,13 @@ func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
|
|||
|
||||
stripper := bluemonday.StrictPolicy()
|
||||
content := stripper.Sanitize(p.Content)
|
||||
mentionRegex := regexp.MustCompile(`@[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]+\b`)
|
||||
mentions := mentionRegex.FindAllString(content, -1)
|
||||
mentions := mentionReg.FindAllString(content, -1)
|
||||
|
||||
for _, handle := range mentions {
|
||||
actorIRI, err := app.db.GetProfilePageFromHandle(app, handle)
|
||||
if err != nil {
|
||||
log.Info("Can't find this user either in the database nor in the remote instance")
|
||||
return nil
|
||||
log.Info("Couldn't find user '%s' locally or remotely", handle)
|
||||
continue
|
||||
}
|
||||
mentionedUsers[handle] = actorIRI
|
||||
}
|
||||
|
@ -1241,9 +1241,9 @@ func getRawPost(app *App, friendlyID string) *RawPost {
|
|||
var isRTL sql.NullBool
|
||||
var lang sql.NullString
|
||||
var ownerID sql.NullInt64
|
||||
var created time.Time
|
||||
var created, updated time.Time
|
||||
|
||||
err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &ownerID)
|
||||
err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, updated, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &updated, &ownerID)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
return &RawPost{Content: "", Found: false, Gone: false}
|
||||
|
@ -1251,7 +1251,7 @@ func getRawPost(app *App, friendlyID string) *RawPost {
|
|||
return &RawPost{Content: "", Found: true, Gone: false}
|
||||
}
|
||||
|
||||
return &RawPost{Title: title, Content: content, Font: font, Created: created, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""}
|
||||
return &RawPost{Title: title, Content: content, Font: font, Created: created, Updated: updated, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""}
|
||||
|
||||
}
|
||||
|
||||
|
@ -1260,15 +1260,15 @@ func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
|
|||
var id, title, content, font string
|
||||
var isRTL sql.NullBool
|
||||
var lang sql.NullString
|
||||
var created time.Time
|
||||
var created, updated time.Time
|
||||
var ownerID null.Int
|
||||
var views int64
|
||||
var err error
|
||||
|
||||
if app.cfg.App.SingleUser {
|
||||
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID)
|
||||
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, updated, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &updated, &ownerID)
|
||||
} else {
|
||||
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = (SELECT id FROM collections WHERE alias = ?)", slug, collAlias).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID)
|
||||
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, updated, owner_id FROM posts WHERE slug = ? AND collection_id = (SELECT id FROM collections WHERE alias = ?)", slug, collAlias).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &updated, &ownerID)
|
||||
}
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
|
@ -1284,6 +1284,7 @@ func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
|
|||
Content: content,
|
||||
Font: font,
|
||||
Created: created,
|
||||
Updated: updated,
|
||||
IsRTL: isRTL,
|
||||
Language: lang,
|
||||
OwnerID: ownerID.Int64,
|
||||
|
@ -1544,6 +1545,13 @@ func (rp *RawPost) Created8601() string {
|
|||
return rp.Created.Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
|
||||
func (rp *RawPost) Updated8601() string {
|
||||
if rp.Updated.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return rp.Updated.Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
|
||||
var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|image)$`)
|
||||
|
||||
func (p *Post) extractImages() {
|
||||
|
|
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
|
||||
|
@ -114,6 +115,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
|||
apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST")
|
||||
apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST")
|
||||
apiMe.HandleFunc("/import", handler.User(handleImport)).Methods("POST")
|
||||
apiMe.HandleFunc("/oauth/remove", handler.User(removeOauth)).Methods("POST")
|
||||
|
||||
// Sign up validation
|
||||
write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST")
|
||||
|
|
37
scripts/invalidate-css.sh
Executable file
37
scripts/invalidate-css.sh
Executable file
|
@ -0,0 +1,37 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# Copyright © 2020 A Bunch Tell LLC.
|
||||
#
|
||||
# This file is part of WriteFreely.
|
||||
#
|
||||
# WriteFreely is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License, included
|
||||
# in the LICENSE file in this source code package.
|
||||
#
|
||||
###############################################################################
|
||||
#
|
||||
# WriteFreely CSS invalidation script
|
||||
#
|
||||
# usage: ./invalidate-css.sh <build-directory>
|
||||
#
|
||||
# This script provides an automated way to invalidate stylesheets cached in the
|
||||
# browser. It uses the last git commit hashes of the most frequently modified
|
||||
# LESS files in the project and appends them to the stylesheet `href` in all
|
||||
# template files.
|
||||
#
|
||||
# This is designed to be used when building a WriteFreely release.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
# Get parent build directory from first argument
|
||||
buildDir=$1
|
||||
|
||||
# Get short hash of each primary LESS file's last commit
|
||||
cssHash=$(git log -n 1 --pretty=format:%h -- less/core.less)
|
||||
cssNewHash=$(git log -n 1 --pretty=format:%h -- less/new-core.less)
|
||||
cssPadHash=$(git log -n 1 --pretty=format:%h -- less/pad.less)
|
||||
|
||||
echo "Adding write.css version ($cssHash $cssNewHash $cssPadHash) to .tmpl files..."
|
||||
cd "$buildDir/templates" || exit 1
|
||||
find . -type f -name "*.tmpl" -print0 | xargs -0 sed -i "s/write.css/write.css?${cssHash}${cssNewHash}${cssPadHash}/g"
|
||||
find . -type f -name "*.tmpl" -print0 | xargs -0 sed -i "s/{{.Theme}}.css/{{.Theme}}.css?${cssHash}${cssNewHash}${cssPadHash}/g"
|
BIN
static/img/mark/gitlab.png
Normal file
BIN
static/img/mark/gitlab.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1,005 B |
BIN
static/img/mark/slack.png
Normal file
BIN
static/img/mark/slack.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
BIN
static/img/mark/writeas.png
Normal file
BIN
static/img/mark/writeas.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.8 KiB |
|
@ -116,13 +116,27 @@ var H = {
|
|||
save: function($el, key) {
|
||||
localStorage.setItem(key, $el.el.value);
|
||||
},
|
||||
load: function($el, key, onlyLoadPopulated) {
|
||||
load: function($el, key, onlyLoadPopulated, postUpdated) {
|
||||
var val = localStorage.getItem(key);
|
||||
if (onlyLoadPopulated && val == null) {
|
||||
// Do nothing
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
$el.el.value = val;
|
||||
if (postUpdated != null) {
|
||||
var lastLocalPublishStr = localStorage.getItem(key+'-published');
|
||||
if (lastLocalPublishStr != null && lastLocalPublishStr != '') {
|
||||
try {
|
||||
var lastLocalPublish = new Date(lastLocalPublishStr);
|
||||
if (postUpdated > lastLocalPublish) {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("unable to parse draft updated time");
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
set: function(key, value) {
|
||||
localStorage.setItem(key, value);
|
||||
|
|
|
@ -37,6 +37,7 @@ var (
|
|||
"localstr": localStr,
|
||||
"localhtml": localHTML,
|
||||
"tolower": strings.ToLower,
|
||||
"title": strings.Title,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -16,6 +16,8 @@
|
|||
<textarea id="writer" placeholder="Write..." class="{{.Post.Font}}" autofocus>{{if .Post.Title}}# {{.Post.Title}}
|
||||
|
||||
{{end}}{{.Post.Content}}</textarea>
|
||||
|
||||
<div class="alert success hidden" id="edited-elsewhere">This post has been updated elsewhere since you last published! <a href="#" id="erase-edit">Delete draft and reload</a>.</div>
|
||||
|
||||
<header id="tools">
|
||||
<div id="clip">
|
||||
|
@ -36,6 +38,7 @@
|
|||
<script>
|
||||
var $writer = H.getEl('writer');
|
||||
var $btnPublish = H.getEl('publish');
|
||||
var $btnEraseEdit = H.getEl('edited-elsewhere');
|
||||
var $wc = H.getEl("wc");
|
||||
var updateWordCount = function() {
|
||||
var words = 0;
|
||||
|
@ -58,7 +61,17 @@
|
|||
};
|
||||
{{if .Post.Id}}var draftDoc = 'draft{{.Post.Id}}';
|
||||
var origDoc = '{{.Post.Content}}';{{else}}var draftDoc = 'lastDoc';{{end}}
|
||||
H.load($writer, draftDoc, true);
|
||||
var updatedStr = '{{.Post.Updated8601}}';
|
||||
var updated = null;
|
||||
if (updatedStr != '') {
|
||||
updated = new Date(updatedStr);
|
||||
}
|
||||
var ok = H.load($writer, draftDoc, true, updated);
|
||||
if (!ok) {
|
||||
// Show "edited elsewhere" warning
|
||||
$btnEraseEdit.el.classList.remove('hidden');
|
||||
}
|
||||
var defaultTimeSet = false;
|
||||
updateWordCount();
|
||||
|
||||
var typingTimer;
|
||||
|
@ -130,6 +143,7 @@
|
|||
data = JSON.parse(http.responseText);
|
||||
id = data.data.id;
|
||||
nextURL = '{{if .SingleUser}}/d{{end}}/'+id;
|
||||
localStorage.setItem('draft'+id+'-published', new Date().toISOString());
|
||||
|
||||
{{ if not .Post.Id }}
|
||||
// Post created
|
||||
|
@ -198,6 +212,13 @@
|
|||
publish(content, selectedFont);
|
||||
}
|
||||
});
|
||||
H.getEl('erase-edit').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
H.remove(draftDoc);
|
||||
H.remove(draftDoc+'-published');
|
||||
justPublished = true; // Block auto-save
|
||||
location.reload();
|
||||
});
|
||||
|
||||
WebFontConfig = {
|
||||
custom: { families: [ 'Lora:400,700:latin' ], urls: [ '/css/fonts.css' ] }
|
||||
|
@ -207,12 +228,20 @@
|
|||
var doneTyping = function() {
|
||||
if (draftDoc == 'lastDoc' || $writer.el.value != origDoc) {
|
||||
H.save($writer, draftDoc);
|
||||
if (!defaultTimeSet) {
|
||||
var lastLocalPublishStr = localStorage.getItem(draftDoc+'-published');
|
||||
if (lastLocalPublishStr == null || lastLocalPublishStr == '') {
|
||||
localStorage.setItem(draftDoc+'-published', updatedStr);
|
||||
}
|
||||
defaultTimeSet = true;
|
||||
}
|
||||
updateWordCount();
|
||||
}
|
||||
};
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
if (draftDoc != 'lastDoc' && $writer.el.value == origDoc) {
|
||||
H.remove(draftDoc);
|
||||
H.remove(draftDoc+'-published');
|
||||
} else if (!justPublished) {
|
||||
doneTyping();
|
||||
}
|
||||
|
|
|
@ -47,8 +47,8 @@
|
|||
{{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}}
|
||||
{{ end }}
|
||||
{{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read"{{if eq .Path "/read"}} class="selected"{{end}}>Reader</a>{{end}}
|
||||
{{if and (and (and .Chorus .OpenRegistration) (not .Username)) (or (not .Private) (ne .Landing ""))}}<a href="/signup"{{if eq .Path "/signup"}} class="selected"{{end}}>Sign up</a>{{end}}
|
||||
{{if not .Username}}<a href="/login"{{if eq .Path "/login"}} class="selected"{{end}}>Log in</a>{{else if .SimpleNav}}<a href="/me/logout">Log out</a>{{end}}
|
||||
{{if eq .SignupPath "/signup"}}<a href="/signup"{{if eq .Path "/signup"}} class="selected"{{end}}>Sign up</a>{{end}}
|
||||
{{if and (not .Username) (not .Private)}}<a href="/login"{{if eq .Path "/login"}} class="selected"{{end}}>Log in</a>{{else if .SimpleNav}}<a href="/me/logout">Log out</a>{{end}}
|
||||
{{ end }}
|
||||
</nav>
|
||||
{{if .Chorus}}{{if .Username}}<div class="right-side" style="font-size: 0.86em;">
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -89,8 +89,8 @@ body#collection header nav.tabs a:first-child {
|
|||
{{template "posts" .}}
|
||||
|
||||
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
|
||||
{{if or (and .Format.Ascending (lt .CurrentPage .TotalPages)) (isRTL .Direction)}}
|
||||
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
|
||||
{{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}}
|
||||
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
|
||||
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} ⇢</a>{{end}}
|
||||
{{else}}
|
||||
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}}
|
||||
|
|
|
@ -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) .IsFound}}<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>
|
||||
|
|
|
@ -89,8 +89,8 @@
|
|||
{{template "posts" .}}
|
||||
|
||||
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
|
||||
{{if or (and .Format.Ascending (lt .CurrentPage .TotalPages)) (isRTL .Direction)}}
|
||||
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
|
||||
{{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}}
|
||||
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
|
||||
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} ⇢</a>{{end}}
|
||||
{{else}}
|
||||
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<a class="home" href="/">{{.SiteName}}</a>
|
||||
{{if not .SingleUser}}
|
||||
<a href="/about">about</a>
|
||||
{{if .LocalTimeline}}<a href="/read">reader</a>{{end}}
|
||||
{{if and .LocalTimeline .CanViewReader}}<a href="/read">reader</a>{{end}}
|
||||
{{if .Username}}<a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a>{{end}}
|
||||
<a href="/privacy">privacy</a>
|
||||
<p style="font-size: 0.9em">powered by <a href="https://writefreely.org">writefreely</a></p>
|
||||
|
|
|
@ -16,6 +16,8 @@
|
|||
<textarea id="writer" placeholder="Write..." class="{{.Post.Font}}" autofocus>{{if .Post.Title}}# {{.Post.Title}}
|
||||
|
||||
{{end}}{{.Post.Content}}</textarea>
|
||||
|
||||
<div class="alert success hidden" id="edited-elsewhere">This post has been updated elsewhere since you last published! <a href="#" id="erase-edit">Delete draft and reload</a>.</div>
|
||||
|
||||
<header id="tools">
|
||||
<div id="clip">
|
||||
|
@ -88,6 +90,7 @@
|
|||
}
|
||||
var $writer = H.getEl('writer');
|
||||
var $btnPublish = H.getEl('publish');
|
||||
var $btnEraseEdit = H.getEl('edited-elsewhere');
|
||||
var $wc = H.getEl("wc");
|
||||
var updateWordCount = function() {
|
||||
var words = 0;
|
||||
|
@ -110,7 +113,17 @@
|
|||
};
|
||||
{{if .Post.Id}}var draftDoc = 'draft{{.Post.Id}}';
|
||||
var origDoc = '{{.Post.Content}}';{{else}}var draftDoc = 'lastDoc';{{end}}
|
||||
H.load($writer, draftDoc, true);
|
||||
var updatedStr = '{{.Post.Updated8601}}';
|
||||
var updated = null;
|
||||
if (updatedStr != '') {
|
||||
updated = new Date(updatedStr);
|
||||
}
|
||||
var ok = H.load($writer, draftDoc, true, updated);
|
||||
if (!ok) {
|
||||
// Show "edited elsewhere" warning
|
||||
$btnEraseEdit.el.classList.remove('hidden');
|
||||
}
|
||||
var defaultTimeSet = false;
|
||||
updateWordCount();
|
||||
|
||||
var typingTimer;
|
||||
|
@ -190,6 +203,7 @@
|
|||
data = JSON.parse(http.responseText);
|
||||
id = data.data.id;
|
||||
nextURL = '{{if .SingleUser}}/d{{end}}/'+id;
|
||||
localStorage.setItem('draft'+id+'-published', new Date().toISOString());
|
||||
|
||||
{{ if not .Post.Id }}
|
||||
// Post created
|
||||
|
@ -258,6 +272,13 @@
|
|||
publish(content, selectedFont);
|
||||
}
|
||||
});
|
||||
H.getEl('erase-edit').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
H.remove(draftDoc);
|
||||
H.remove(draftDoc+'-published');
|
||||
justPublished = true; // Block auto-save
|
||||
location.reload();
|
||||
});
|
||||
|
||||
H.getEl('toggle-theme').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
@ -338,12 +359,20 @@
|
|||
var doneTyping = function() {
|
||||
if (draftDoc == 'lastDoc' || $writer.el.value != origDoc) {
|
||||
H.save($writer, draftDoc);
|
||||
if (!defaultTimeSet) {
|
||||
var lastLocalPublishStr = localStorage.getItem(draftDoc+'-published');
|
||||
if (lastLocalPublishStr == null || lastLocalPublishStr == '') {
|
||||
localStorage.setItem(draftDoc+'-published', updatedStr);
|
||||
}
|
||||
defaultTimeSet = true;
|
||||
}
|
||||
updateWordCount();
|
||||
}
|
||||
};
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
if (draftDoc != 'lastDoc' && $writer.el.value == origDoc) {
|
||||
H.remove(draftDoc);
|
||||
H.remove(draftDoc+'-published');
|
||||
} else if (!justPublished) {
|
||||
doneTyping();
|
||||
}
|
||||
|
|
|
@ -15,6 +15,13 @@ form dt {
|
|||
p.docs {
|
||||
font-size: 0.86em;
|
||||
}
|
||||
input[type=checkbox] {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
select {
|
||||
font-size: 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="content-container snug">
|
||||
|
@ -42,7 +49,7 @@ p.docs {
|
|||
<div class="features row">
|
||||
<div>
|
||||
Host
|
||||
<p>The address where your site lives.</p>
|
||||
<p>The public address where users will access your site, starting with <code>http://</code> or <code>https://</code>.</p>
|
||||
</div>
|
||||
<div>{{.Config.Host}}</div>
|
||||
</div>
|
||||
|
@ -56,24 +63,57 @@ p.docs {
|
|||
<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>
|
||||
<p>The page that logged-out visitors will see first. This should be an absolute path like: <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>
|
||||
<p>Allow anyone who visits the site to create an account.</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>
|
||||
<div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="user_invites">
|
||||
Allow invitations from...
|
||||
<p>Choose who is allowed to invite new people.</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{{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><label for="private">
|
||||
Private Instance
|
||||
<p>Limit site access to people 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="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">
|
||||
<div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="max_blogs">
|
||||
|
@ -97,44 +137,11 @@ p.docs {
|
|||
<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>
|
||||
<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="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><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">
|
||||
<input type="submit" value="Save Settings" />
|
||||
|
|
|
@ -4,7 +4,13 @@
|
|||
<style type="text/css">
|
||||
.option { margin: 2em 0em; }
|
||||
h3 { font-weight: normal; }
|
||||
.section > *:not(input) { font-size: 0.86em; }
|
||||
.section p, .section label {
|
||||
font-size: 0.86em;
|
||||
}
|
||||
.oauth-provider img {
|
||||
max-height: 2.75em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
<div class="content-container snug">
|
||||
{{if .Silenced}}
|
||||
|
@ -62,10 +68,64 @@ h3 { font-weight: normal; }
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" style="text-align: center; margin-top: 4em;">
|
||||
<div class="option" style="text-align: center;">
|
||||
<input type="submit" value="Save changes" tabindex="4" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{ if .OauthSection }}
|
||||
<hr />
|
||||
|
||||
{{ if .OauthAccounts }}
|
||||
<div class="option">
|
||||
<h2>Linked Accounts</h2>
|
||||
<p>These are your linked external accounts.</p>
|
||||
{{ range $oauth_account := .OauthAccounts }}
|
||||
<form method="post" action="/api/me/oauth/remove" autocomplete="false">
|
||||
<input type="hidden" name="provider" value="{{ $oauth_account.Provider }}" />
|
||||
<input type="hidden" name="client_id" value="{{ $oauth_account.ClientID }}" />
|
||||
<input type="hidden" name="remote_user_id" value="{{ $oauth_account.RemoteUserID }}" />
|
||||
<div class="section oauth-provider">
|
||||
<img src="/img/mark/{{$oauth_account.Provider}}.png" alt="{{ $oauth_account.Provider | title }}" />
|
||||
<input type="submit" value="Remove {{ $oauth_account.Provider | title }}" />
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if or .OauthSlack .OauthWriteAs .OauthGitLab }}
|
||||
<div class="option">
|
||||
<h2>Link External Accounts</h2>
|
||||
<p>Connect additional accounts to enable logging in with those providers, instead of using your username and password.</p>
|
||||
<div class="row">
|
||||
{{ if .OauthWriteAs }}
|
||||
<div class="section oauth-provider">
|
||||
<img src="/img/mark/writeas.png" alt="Write.as" />
|
||||
<a class="btn cta loginbtn" id="writeas-login" href="/oauth/write.as?attach=t">
|
||||
Link <strong>Write.as</strong>
|
||||
</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .OauthSlack }}
|
||||
<div class="section oauth-provider">
|
||||
<img src="/img/mark/slack.png" alt="Slack" />
|
||||
<a class="btn cta loginbtn" href="/oauth/slack?attach=t">
|
||||
Link <strong>Slack</strong>
|
||||
</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .OauthGitLab }}
|
||||
<div class="section oauth-provider">
|
||||
<img src="/img/mark/gitlab.png" alt="GitLab" />
|
||||
<a class="btn cta loginbtn" id="gitlab-login" href="/oauth/gitlab?attach=t">
|
||||
Link <strong>{{.GitLabDisplayName}}</strong>
|
||||
</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
|
|
@ -101,20 +101,20 @@ func RemoteLookup(handle string) string {
|
|||
parts := strings.Split(handle, "@")
|
||||
resp, err := http.Get("https://" + parts[1] + "/.well-known/webfinger?resource=acct:" + handle)
|
||||
if err != nil {
|
||||
log.Error("Error performing webfinger request", err)
|
||||
log.Error("Error on webfinger request: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Error("Error reading webfinger response", err)
|
||||
log.Error("Error on webfinger response: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var result webfinger.Resource
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
log.Error("Unsupported webfinger response received: %v", err)
|
||||
log.Error("Unable to parse webfinger response: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue