diff --git a/Makefile b/Makefile index 85f02d3..05bc1c6 100644 --- a/Makefile +++ b/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) diff --git a/README.md b/README.md index 68da89b..163eab7 100644 --- a/README.md +++ b/README.md @@ -7,81 +7,76 @@ - - - + + +
-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. + +Not found.
{{end}}")), Gone: template.Must(template.New("").Parse("{{define \"base\"}}Gone.
{{end}}")), InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}Internal server error.
{{end}}")), + UnavailableError: template.Must(template.New("").Parse("{{define \"base\"}}Service is temporarily unavailable.
{{end}}")), Blank: template.Must(template.New("").Parse("{{define \"base\"}}{{.Content}}
{{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 diff --git a/invites.go b/invites.go index d5d024a..10416b2 100644 --- a/invites.go +++ b/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 { diff --git a/less/app.less b/less/app.less index ec3472d..e1cf5ea 100644 --- a/less/app.less +++ b/less/app.less @@ -5,6 +5,7 @@ @import "post-temp"; @import "effects"; @import "admin"; +@import "login"; @import "pages/error"; @import "lib/elements"; @import "lib/material"; diff --git a/less/core.less b/less/core.less index f2eaef3..c1cfad8 100644 --- a/less/core.less +++ b/less/core.less @@ -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; diff --git a/less/login.less b/less/login.less new file mode 100644 index 0000000..473d26f --- /dev/null +++ b/less/login.less @@ -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; + } +} \ No newline at end of file diff --git a/less/new-core.less b/less/new-core.less index 802f34d..87d8158 100644 --- a/less/new-core.less +++ b/less/new-core.less @@ -1,4 +1,4 @@ -@actionNavColor: #999; +@actionNavColor: #767676; body { margin: 0; @@ -58,7 +58,7 @@ header { } p { &.description { - color: #666; + color: #444; font-size: 1.1em; margin-top: 0.5em; line-height: 1.5; @@ -113,7 +113,7 @@ textarea { ul { margin: 0; padding: 0 0 0 1em; - line-height: 1.4; + line-height: 1.5; &.collections, &.posts, &.integrations { list-style: none; @@ -206,7 +206,7 @@ code, textarea#embed { font-weight: normal; } p { - line-height: 1.4; + line-height: 1.5; } li { margin: 0.3em 0; diff --git a/less/pad.less b/less/pad.less index a132b30..d3e4350 100644 --- a/less/pad.less +++ b/less/pad.less @@ -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) { diff --git a/less/post-temp.less b/less/post-temp.less index 8173864..7ab5d92 100644 --- a/less/post-temp.less +++ b/less/post-temp.less @@ -49,7 +49,7 @@ body#post article, pre, .hljs { border-left: 4px solid #ddd; padding: 0 1em; margin: 0.5em; - color: #777; + color: #767676; display: inline-block; p { @@ -58,7 +58,7 @@ body#post article, pre, .hljs { } } .article-p() { - line-height: 1.4em; + line-height: 1.5; white-space: pre-wrap; /* CSS 3 */ white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ white-space: -pre-wrap; /* Opera 4-6 */ diff --git a/migrations/migrations.go b/migrations/migrations.go index 41f036f..6810bff 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -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 diff --git a/migrations/v4.go b/migrations/v4.go index c075dd8..7d73f96 100644 --- a/migrations/v4.go +++ b/migrations/v4.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 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 { diff --git a/migrations/v5.go b/migrations/v5.go index 94e3944..f93d067 100644 --- a/migrations/v5.go +++ b/migrations/v5.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 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 { diff --git a/migrations/v6.go b/migrations/v6.go index c6f5012..8e0be78 100644 --- a/migrations/v6.go +++ b/migrations/v6.go @@ -1,5 +1,5 @@ /* - * Copyright © 2019 A Bunch Tell LLC. + * Copyright © 2019-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -13,7 +13,7 @@ package migrations func supportActivityPubMentions(db *datastore) error { t, err := db.Begin() - _, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN handle ` + db.typeVarChar(255) + ` DEFAULT '' NOT NULL`) + _, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN handle ` + db.typeVarChar(255) + ` NULL`) if err != nil { t.Rollback() return err diff --git a/migrations/v7.go b/migrations/v7.go new file mode 100644 index 0000000..3090cd9 --- /dev/null +++ b/migrations/v7.go @@ -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 + }) +} diff --git a/migrations/v8.go b/migrations/v8.go new file mode 100644 index 0000000..2318c4e --- /dev/null +++ b/migrations/v8.go @@ -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 + }) +} diff --git a/migrations/v9.go b/migrations/v9.go new file mode 100644 index 0000000..c6b832e --- /dev/null +++ b/migrations/v9.go @@ -0,0 +1,37 @@ +/* + * Copyright © 2020 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func optimizeDrafts(db *datastore) error { + t, err := db.Begin() + if err != nil { + t.Rollback() + return err + } + + if db.driverName == driverSQLite { + _, err = t.Exec(`CREATE INDEX key_owner_post_id ON posts (owner_id, id)`) + } else { + _, err = t.Exec(`ALTER TABLE posts ADD INDEX(owner_id, id)`) + } + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + + return nil +} diff --git a/oauth.go b/oauth.go index caf8189..b5c88aa 100644 --- a/oauth.go +++ b/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) diff --git a/oauth_gitlab.go b/oauth_gitlab.go new file mode 100644 index 0000000..c9c74aa --- /dev/null +++ b/oauth_gitlab.go @@ -0,0 +1,115 @@ +package writefreely + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" +) + +type gitlabOauthClient struct { + ClientID string + ClientSecret string + AuthLocation string + ExchangeLocation string + InspectLocation string + CallbackLocation string + HttpClient HttpClient +} + +var _ oauthClient = gitlabOauthClient{} + +const ( + gitlabHost = "https://gitlab.com" + gitlabDisplayName = "GitLab" +) + +func (c gitlabOauthClient) GetProvider() string { + return "gitlab" +} + +func (c gitlabOauthClient) GetClientID() string { + return c.ClientID +} + +func (c gitlabOauthClient) GetCallbackLocation() string { + return c.CallbackLocation +} + +func (c gitlabOauthClient) buildLoginURL(state string) (string, error) { + u, err := url.Parse(c.AuthLocation) + if err != nil { + return "", err + } + q := u.Query() + q.Set("client_id", c.ClientID) + q.Set("redirect_uri", c.CallbackLocation) + q.Set("response_type", "code") + q.Set("state", state) + q.Set("scope", "read_user") + u.RawQuery = q.Encode() + return u.String(), nil +} + +func (c gitlabOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) { + form := url.Values{} + form.Add("grant_type", "authorization_code") + form.Add("redirect_uri", c.CallbackLocation) + form.Add("scope", "read_user") + form.Add("code", code) + req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.WithContext(ctx) + req.Header.Set("User-Agent", "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 +} diff --git a/oauth_signup.go b/oauth_signup.go index 220afbd..cbe4f60 100644 --- a/oauth_signup.go +++ b/oauth_signup.go @@ -38,6 +38,7 @@ type viewOauthSignupVars struct { Provider string ClientID string TokenHash string + InviteCode string LoginUsername string Alias string // TODO: rename this to match the data it represents: the collection title @@ -57,6 +58,7 @@ const ( oauthParamAlias = "alias" oauthParamEmail = "email" oauthParamPassword = "password" + oauthParamInviteCode = "invite_code" ) type oauthSignupPageParams struct { @@ -68,6 +70,7 @@ type oauthSignupPageParams struct { ClientID string Provider string TokenHash string + InviteCode string } func (p oauthSignupPageParams) HashTokenParams(key string) string { @@ -92,6 +95,7 @@ func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.R TokenRemoteUser: r.FormValue(oauthParamTokenRemoteUserID), ClientID: r.FormValue(oauthParamClientID), Provider: r.FormValue(oauthParamProvider), + InviteCode: r.FormValue(oauthParamInviteCode), } if tp.HashTokenParams(h.Config.Server.HashSeed) != r.FormValue(oauthParamHash) { return impart.HTTPError{Status: http.StatusBadRequest, Message: "Request has been tampered with."} @@ -128,6 +132,14 @@ func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.R return h.showOauthSignupPage(app, w, r, tp, err) } + // Log invite if needed + if tp.InviteCode != "" { + err = app.db.CreateInvitedUser(tp.InviteCode, newUser.ID) + if err != nil { + return err + } + } + err = h.DB.RecordRemoteUserID(r.Context(), newUser.ID, r.FormValue(oauthParamTokenRemoteUserID), r.FormValue(oauthParamProvider), r.FormValue(oauthParamClientID), r.FormValue(oauthParamAccessToken)) if err != nil { return h.showOauthSignupPage(app, w, r, tp, err) @@ -195,6 +207,7 @@ func (h oauthHandler) showOauthSignupPage(app *App, w http.ResponseWriter, r *ht Provider: tp.Provider, ClientID: tp.ClientID, TokenHash: tp.TokenHash, + InviteCode: tp.InviteCode, LoginUsername: username, Alias: collTitle, diff --git a/oauth_slack.go b/oauth_slack.go index 35db156..c881ab6 100644 --- a/oauth_slack.go +++ b/oauth_slack.go @@ -13,8 +13,6 @@ package writefreely import ( "context" "errors" - "fmt" - "github.com/writeas/nerds/store" "github.com/writeas/slug" "net/http" "net/url" @@ -167,7 +165,7 @@ func (c slackOauthClient) inspectOauthAccessToken(ctx context.Context, accessTok func (resp slackUserIdentityResponse) InspectResponse() *InspectResponse { return &InspectResponse{ UserID: resp.User.ID, - Username: fmt.Sprintf("%s-%s", slug.Make(resp.User.Name), store.GenerateRandomString("0123456789bcdfghjklmnpqrstvwxyz", 5)), + Username: slug.Make(resp.User.Name), DisplayName: resp.User.Name, Email: resp.User.Email, } diff --git a/oauth_test.go b/oauth_test.go index 2e293e7..96f65b2 100644 --- a/oauth_test.go +++ b/oauth_test.go @@ -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") }, } diff --git a/pages/503.tmpl b/pages/503.tmpl new file mode 100644 index 0000000..70c6c78 --- /dev/null +++ b/pages/503.tmpl @@ -0,0 +1,7 @@ +{{define "head"}}The words aren't coming to me. 🗅
+We couldn't serve this page due to high server load. This should only be temporary.
+{{if .Message}}{{.Message}}{{else}}No account yet? Sign up to start a blog.{{end}}
{{end}} + {{if and (not .SingleUser) .OpenRegistration}}{{if .Message}}{{.Message}}{{else}}No account yet? Sign up to start a blog.{{end}}
{{end}} {{end}} diff --git a/pages/signup.tmpl b/pages/signup.tmpl index 7c8707c..c17aee3 100644 --- a/pages/signup.tmpl +++ b/pages/signup.tmpl @@ -70,6 +70,25 @@ form dd { {{end}}or
+