diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d1a7fc2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + open-pull-requests-limit: 50 + schedule: + interval: "monthly" diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index bd71237..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "static/js/mathjax"] - path = static/js/mathjax - url = https://github.com/mathjax/MathJax.git 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 @@ Latest release - - Go Report Card - Build status + + Go Report Card +

  -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. + +

Write.as

+ +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). diff --git a/account.go b/account.go index 5dba924..ba013c2 100644 --- a/account.go +++ b/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" @@ -48,6 +49,7 @@ type ( Separator template.HTML IsAdmin bool CanInvite bool + CollAlias string } ) @@ -70,7 +72,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 ( @@ -85,6 +87,11 @@ func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error { } func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) { + if app.cfg.App.DisablePasswordAuth { + err := ErrDisabledPasswordAuth + return nil, err + } + reqJSON := IsJSON(r) // Get params @@ -167,11 +174,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,20 +305,18 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error { p := &struct { page.StaticPage + *OAuthButtons To string Message template.HTML Flashes []template.HTML LoginUsername string - OauthSlack bool - OauthWriteAs bool }{ - pageForReq(app, r), - r.FormValue("to"), - template.HTML(""), - []template.HTML{}, - getTempInfo(app, "login-user", r, w), - app.Config().SlackOauth.ClientID != "", - app.Config().WriteAsOauth.ClientID != "", + StaticPage: pageForReq(app, r), + OAuthButtons: NewOAuthButtons(app.Config()), + To: r.FormValue("to"), + Message: template.HTML(""), + Flashes: []template.HTML{}, + LoginUsername: getTempInfo(app, "login-user", r, w), } if earlyError != "" { @@ -390,6 +391,11 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error { var err error var signin userCredentials + if app.cfg.App.DisablePasswordAuth { + err := ErrDisabledPasswordAuth + return err + } + // Log in with one-time token if one is given if oneTimeToken != "" { log.Info("Login: Logging user in via token.") @@ -488,6 +494,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."} } @@ -832,6 +841,7 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques Collection: c, Silenced: silenced, } + obj.UserPage.CollAlias = c.Alias showUserPage(w, "collection", obj) return nil @@ -1011,6 +1021,7 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error TopPosts: topPosts, Silenced: silenced, } + obj.UserPage.CollAlias = c.Alias if app.cfg.App.Federation { folls, err := app.db.GetAPFollowers(c) if err != nil { @@ -1038,18 +1049,68 @@ 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 != "" + enableOauthGeneric := app.Config().GenericOauth.ClientID != "" + enableOauthGitea := app.Config().GiteaOauth.ClientID != "" + + oauthAccounts, err := app.db.GetOauthAccounts(r.Context(), u.ID) + if err != nil { + log.Error("Unable to get oauth accounts for settings: %s", err) + return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."} + } + for idx, oauthAccount := range oauthAccounts { + switch oauthAccount.Provider { + case "slack": + enableOauthSlack = false + case "write.as": + enableOauthWriteAs = false + case "gitlab": + enableOauthGitLab = false + case "generic": + oauthAccounts[idx].DisplayName = app.Config().GenericOauth.DisplayName + oauthAccounts[idx].AllowDisconnect = app.Config().GenericOauth.AllowDisconnect + enableOauthGeneric = false + case "gitea": + enableOauthGitea = false + } + } + + displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || enableOauthGeneric || enableOauthGitea || len(oauthAccounts) > 0 + obj := struct { *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 + OauthGeneric bool + OauthGenericDisplayName string + OauthGitea bool + GiteaDisplayName 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), + OauthGeneric: enableOauthGeneric, + OauthGenericDisplayName: config.OrDefaultString(app.Config().GenericOauth.DisplayName, genericOauthDisplayName), + OauthGitea: enableOauthGitea, + GiteaDisplayName: config.OrDefaultString(app.Config().GiteaOauth.DisplayName, giteaDisplayName), } showUserPage(w, "settings", obj) @@ -1094,6 +1155,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 { diff --git a/activitypub.go b/activitypub.go index c3df29f..0e69075 100644 --- a/activitypub.go +++ b/activitypub.go @@ -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 } @@ -491,7 +494,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m r, _ := http.NewRequest("POST", url, bytes.NewBuffer(b)) r.Header.Add("Content-Type", "application/activity+json") - r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")") + r.Header.Set("User-Agent", ServerUserAgent(hostName)) h := sha256.New() h.Write(b) r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil))) @@ -541,7 +544,7 @@ func resolveIRI(hostName, url string) ([]byte, error) { r, _ := http.NewRequest("GET", url, nil) r.Header.Add("Accept", "application/activity+json") - r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")") + r.Header.Set("User-Agent", ServerUserAgent(hostName)) if debugging { dump, err := httputil.DumpRequestOut(r, true) @@ -696,6 +699,10 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error { // I don't believe we'd ever have too many mentions in a single post that this // could become a burden. remoteUser, err := getRemoteUser(app, tag.HRef) + if err != nil { + log.Error("Unable to find remote user %s. Skipping: %v", tag.HRef, err) + continue + } err = makeActivityPost(app.cfg.App.Host, actor, remoteUser.Inbox, activity) if err != nil { log.Error("Couldn't post! %v", err) @@ -708,7 +715,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 +725,8 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) { return nil, err } + u.Handle = handle.String + return &u, nil } diff --git a/app.go b/app.go index dd05c95..2aed437 100644 --- a/app.go +++ b/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} } @@ -234,6 +238,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error { p := struct { page.StaticPage + *OAuthButtons Flashes []template.HTML Banner template.HTML Content template.HTML @@ -241,6 +246,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error { ForcedLanding bool }{ StaticPage: pageForReq(app, r), + OAuthButtons: NewOAuthButtons(app.Config()), ForcedLanding: forceLanding, } @@ -409,6 +415,11 @@ func Serve(app *App, r *mux.Router) { os.Exit(0) }() + // Start gopher server + if app.cfg.Server.GopherPort > 0 && !app.cfg.App.Private { + go initGopher(app) + } + // Start web application server var bindAddress = app.cfg.Server.Bind if bindAddress == "" { @@ -744,7 +755,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 { @@ -881,3 +892,13 @@ func adminInitDatabase(app *App) error { log.Info("Done.") return nil } + +// ServerUserAgent returns a User-Agent string to use in external requests. The +// hostName parameter may be left empty. +func ServerUserAgent(hostName string) string { + hostUAStr := "" + if hostName != "" { + hostUAStr = "; +" + hostName + } + return "Go (" + serverSoftware + "/" + softwareVer + hostUAStr + ")" +} diff --git a/collections.go b/collections.go index 9688ad9..edde677 100644 --- a/collections.go +++ b/collections.go @@ -47,6 +47,7 @@ type ( Language string `schema:"lang" json:"lang,omitempty"` StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"` Script string `datastore:"script" schema:"script" json:"script,omitempty"` + Signature string `datastore:"post_signature" schema:"signature" json:"-"` Public bool `datastore:"public" json:"public"` Visibility collVisibility `datastore:"private" json:"-"` Format string `datastore:"format" json:"format,omitempty"` @@ -91,6 +92,7 @@ type ( Description *string `schema:"description" json:"description"` StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"` Script *sql.NullString `schema:"script" json:"script"` + Signature *sql.NullString `schema:"signature" json:"signature"` Visibility *int `schema:"visibility" json:"public"` Format *sql.NullString `schema:"format" json:"format"` } diff --git a/config.ini.example b/config.ini.example index 7ac944e..8b74ddc 100644 --- a/config.ini.example +++ b/config.ini.example @@ -9,6 +9,7 @@ password = changeme database = writefreely host = db port = 3306 +tls = false [app] site_name = WriteFreely Example Blog! diff --git a/config/config.go b/config/config.go index 78892bf..7b64e02 100644 --- a/config/config.go +++ b/config/config.go @@ -45,6 +45,8 @@ type ( HashSeed string `ini:"hash_seed"` + GopherPort int `ini:"gopher_port"` + Dev bool `ini:"-"` } @@ -57,6 +59,7 @@ type ( Database string `ini:"database"` Host string `ini:"host"` Port int `ini:"port"` + TLS bool `ini:"tls"` } WriteAsOauthCfg struct { @@ -69,6 +72,24 @@ 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"` + } + + GiteaOauthCfg struct { + ClientID string `ini:"client_id"` + ClientSecret string `ini:"client_secret"` + Host string `ini:"host"` + DisplayName string `ini:"display_name"` + CallbackProxy string `ini:"callback_proxy"` + CallbackProxyAPI string `ini:"callback_proxy_api"` + } + SlackOauthCfg struct { ClientID string `ini:"client_id"` ClientSecret string `ini:"client_secret"` @@ -77,6 +98,19 @@ type ( CallbackProxyAPI string `ini:"callback_proxy_api"` } + GenericOauthCfg struct { + ClientID string `ini:"client_id"` + ClientSecret string `ini:"client_secret"` + Host string `ini:"host"` + DisplayName string `ini:"display_name"` + CallbackProxy string `ini:"callback_proxy"` + CallbackProxyAPI string `ini:"callback_proxy_api"` + TokenEndpoint string `ini:"token_endpoint"` + InspectEndpoint string `ini:"inspect_endpoint"` + AuthEndpoint string `ini:"auth_endpoint"` + AllowDisconnect bool `ini:"allow_disconnect"` + } + // AppCfg holds values that affect how the application functions AppCfg struct { SiteName string `ini:"site_name"` @@ -119,6 +153,9 @@ type ( // Check for Updates UpdateChecks bool `ini:"update_checks"` + + // Disable password authentication if use only Oauth + DisablePasswordAuth bool `ini:"disable_password_auth"` } // Config holds the complete configuration for running a writefreely instance @@ -128,6 +165,9 @@ type ( App AppCfg `ini:"app"` SlackOauth SlackOauthCfg `ini:"oauth.slack"` WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"` + GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"` + GiteaOauth GiteaOauthCfg `ini:"oauth.gitea"` + GenericOauth GenericOauthCfg `ini:"oauth.generic"` } ) @@ -183,6 +223,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 == "" { diff --git a/config/setup.go b/config/setup.go index fd5a632..08c479f 100644 --- a/config/setup.go +++ b/config/setup.go @@ -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() diff --git a/database-lib.go b/database-lib.go index b6b4be2..8b28577 100644 --- a/database-lib.go +++ b/database-lib.go @@ -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 +} diff --git a/database-no-sqlite.go b/database-no-sqlite.go index 03d1a32..f2c7ffc 100644 --- a/database-no-sqlite.go +++ b/database-no-sqlite.go @@ -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 +} diff --git a/database-sqlite.go b/database-sqlite.go index bd77e6a..10e701e 100644 --- a/database-sqlite.go +++ b/database-sqlite.go @@ -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 +} diff --git a/database.go b/database.go index cea7a97..8237e41 100644 --- a/database.go +++ b/database.go @@ -14,6 +14,7 @@ import ( "context" "database/sql" "fmt" + "github.com/writeas/web-core/silobridge" wf_db "github.com/writeas/writefreely/db" "net/http" "strings" @@ -39,6 +40,8 @@ import ( const ( mySQLErrDuplicateKey = 1062 mySQLErrCollationMix = 1267 + mySQLErrTooManyConns = 1040 + mySQLErrMaxUserConns = 1203 driverMySQL = "mysql" driverSQLite = "sqlite3" @@ -130,8 +133,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 +179,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."} @@ -786,19 +792,22 @@ func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Coll c := &Collection{} // FIXME: change Collection to reflect database values. Add helper functions to get actual values - var styleSheet, script, format zero.String - row := db.QueryRow("SELECT id, alias, title, description, style_sheet, script, format, owner_id, privacy, view_count FROM collections WHERE "+condition, value) + var styleSheet, script, signature, format zero.String + row := db.QueryRow("SELECT id, alias, title, description, style_sheet, script, post_signature, format, owner_id, privacy, view_count FROM collections WHERE "+condition, value) - err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &styleSheet, &script, &format, &c.OwnerID, &c.Visibility, &c.Views) + err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &styleSheet, &script, &signature, &format, &c.OwnerID, &c.Visibility, &c.Views) switch { 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 } c.StyleSheet = styleSheet.String c.Script = script.String + c.Signature = signature.String c.Format = format.String c.Public = c.IsPublic() @@ -842,7 +851,8 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro SetStringPtr(c.Title, "title"). SetStringPtr(c.Description, "description"). SetNullString(c.StyleSheet, "style_sheet"). - SetNullString(c.Script, "script") + SetNullString(c.Script, "script"). + SetNullString(c.Signature, "post_signature") if c.Format != nil { cf := &CollectionFormat{Format: c.Format.String} @@ -1143,6 +1153,7 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu break } p.extractData() + p.augmentContent(c) p.formatContent(cfg, c, includeFuture) posts = append(posts, p.processPost()) @@ -1207,6 +1218,7 @@ func (db *datastore) GetPostsTagged(cfg *config.Config, c *Collection, tag strin break } p.extractData() + p.augmentContent(c) p.formatContent(cfg, c, includeFuture) posts = append(posts, p.processPost()) @@ -1583,6 +1595,7 @@ func (db *datastore) GetPinnedPosts(coll *CollectionObj, includeFuture bool) (*[ break } p.extractData() + p.augmentContent(&coll.Collection) pp := p.processPost() pp.Collection = coll @@ -1633,6 +1646,40 @@ func (db *datastore) GetPublishableCollections(u *User, hostName string) (*[]Col return c, nil } +func (db *datastore) GetPublicCollections(hostName string) (*[]Collection, error) { + rows, err := db.Query(`SELECT c.id, alias, title, description, privacy, view_count + FROM collections c + LEFT JOIN users u ON u.id = c.owner_id + WHERE c.privacy = 1 AND u.status = 0 + ORDER BY id ASC`) + if err != nil { + log.Error("Failed selecting public collections: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve public collections."} + } + defer rows.Close() + + colls := []Collection{} + for rows.Next() { + c := Collection{} + err = rows.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &c.Visibility, &c.Views) + if err != nil { + log.Error("Failed scanning row: %v", err) + break + } + c.hostName = hostName + c.URL = c.CanonicalURL() + c.Public = c.IsPublic() + + colls = append(colls, c) + } + err = rows.Err() + if err != nil { + log.Error("Error after Next() on rows: %v", err) + } + + return &colls, nil +} + func (db *datastore) GetMeStats(u *User) userMeStats { s := userMeStats{} @@ -2016,7 +2063,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 @@ -2510,20 +2557,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 } @@ -2542,9 +2595,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 { @@ -2573,6 +2626,35 @@ func (db *datastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provi return userID, nil } +type oauthAccountInfo struct { + Provider string + ClientID string + RemoteUserID string + DisplayName string + AllowDisconnect bool +} + +func (db *datastore) GetOauthAccounts(ctx context.Context, userID int64) ([]oauthAccountInfo, error) { + rows, err := db.QueryContext(ctx, "SELECT provider, client_id, remote_user_id FROM oauth_users WHERE user_id = ? ", userID) + if err != nil { + log.Error("Failed selecting from oauth_users: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user oauth accounts."} + } + defer rows.Close() + + var records []oauthAccountInfo + for rows.Next() { + info := oauthAccountInfo{} + err = rows.Scan(&info.Provider, &info.ClientID, &info.RemoteUserID) + if err != nil { + log.Error("Failed scanning GetAllUsers() row: %v", err) + break + } + records = append(records, info) + } + return records, nil +} + // DatabaseInitialized returns whether or not the current datastore has been // initialized with the correct schema. // Currently, it checks to see if the `users` table exists. @@ -2595,6 +2677,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...) } @@ -2605,7 +2692,19 @@ func handleFailedPostInsert(err error) error { } func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, error) { + handle = strings.TrimLeft(handle, "@") actorIRI := "" + parts := strings.Split(handle, "@") + if len(parts) != 2 { + return "", fmt.Errorf("invalid handle format") + } + domain := parts[1] + + // Check non-AP instances + if siloProfileURL := silobridge.Profile(parts[0], domain); siloProfileURL != "" { + return siloProfileURL, nil + } + remoteUser, err := getRemoteUserFromHandle(app, handle) if err != nil { // can't find using handle in the table but the table may already have this user without @@ -2617,21 +2716,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 } } diff --git a/database_test.go b/database_test.go index c4c586a..c114077 100644 --- a/database_test.go +++ b/database_test.go @@ -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) diff --git a/db/create.go b/db/create.go index c384778..648f93a 100644 --- a/db/create.go +++ b/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 } - diff --git a/errors.go b/errors.go index b62fc9e..cf52df1 100644 --- a/errors.go +++ b/errors.go @@ -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."} @@ -50,6 +52,8 @@ var ( ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} ErrUserSilenced = impart.HTTPError{http.StatusForbidden, "Account is silenced."} + + ErrDisabledPasswordAuth = impart.HTTPError{http.StatusForbidden, "Password authentication is disabled."} ) // Post operation errors diff --git a/go.mod b/go.mod index fe5b548..1d03956 100644 --- a/go.mod +++ b/go.mod @@ -3,60 +3,58 @@ module github.com/writeas/writefreely require ( 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/fatih/color v1.9.0 + github.com/go-sql-driver/mysql v1.5.0 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/schema v1.0.2 + github.com/gorilla/feeds v1.1.1 + github.com/gorilla/mux v1.7.4 + github.com/gorilla/schema v1.2.0 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/guregu/null v3.5.0+incompatible + github.com/hashicorp/go-multierror v1.1.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 - github.com/manifoldco/promptui v0.3.2 - github.com/mattn/go-colorable v0.1.0 // indirect - github.com/mattn/go-sqlite3 v1.10.0 - github.com/microcosm-cc/bluemonday v1.0.2 + github.com/manifoldco/promptui v0.7.0 + github.com/mattn/go-sqlite3 v1.14.2 + github.com/microcosm-cc/bluemonday v1.0.4 github.com/mitchellh/go-wordwrap v1.0.0 github.com/nicksnyder/go-i18n v1.10.0 // indirect github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d github.com/pelletier/go-toml v1.2.0 // indirect github.com/pkg/errors v0.8.1 // indirect + github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469 github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect - github.com/stretchr/testify v1.3.0 - github.com/urfave/cli/v2 v2.1.1 + github.com/stretchr/testify v1.6.1 + github.com/urfave/cli/v2 v2.2.0 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/import v0.2.0 + github.com/writeas/impart v1.1.1 + github.com/writeas/import v0.2.1 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/writeas/web-core v1.2.1-0.20200813161734-68a680d1b03c github.com/writefreely/go-nodeinfo v1.2.0 - golang.org/x/crypto v0.0.0-20200109152110-61a87790db17 + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect + golang.org/x/net v0.0.0-20200707034311-ab3426394381 // indirect golang.org/x/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/ini.v1 v1.57.0 src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b // indirect ) diff --git a/go.sum b/go.sum index b0a423a..90c1bdd 100644 --- a/go.sum +++ b/go.sum @@ -2,15 +2,21 @@ code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs= code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/alecthomas/gometalinter v2.0.11+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk= github.com/alecthomas/gometalinter v3.0.0+incompatible h1:e9Zfvfytsw/e6Kd/PYd75wggK+/kX5Xn8IYDUKyc5fU= github.com/alecthomas/gometalinter v3.0.0+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 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/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU= +github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= @@ -27,52 +33,70 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:ma 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= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= 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-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg= github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= 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= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc h1:cJlkeAx1QYgO5N80aF5xRGstVsRQwgLR7uA2FnP1ZjY= github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/feeds v1.1.0 h1:pcgLJhbdYgaUESnj3AmXPcB7cS3vy63+jC/TI14AGXk= github.com/gorilla/feeds v1.1.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/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY= +github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA= +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/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY= +github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= +github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= +github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ= github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/guregu/null v3.4.0+incompatible h1:a4mw37gBO7ypcBlTJeZGuMpSxxFTV9qFfFKgWxQSGaM= github.com/guregu/null v3.4.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM= +github.com/guregu/null v3.5.0+incompatible h1:fSdvRTQtmBA4B4YDZXhLtxTIJZYuUxBFTTHS4B9djG4= +github.com/guregu/null v3.5.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= 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= @@ -90,16 +114,31 @@ github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+L github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/manifoldco/promptui v0.3.2 h1:rir7oByTERac6jhpHUPErHuopoRDvO3jxS+FdadEns8= github.com/manifoldco/promptui v0.3.2/go.mod h1:8JU+igZ+eeiiRku4T5BjtKh2ms8sziGpSYl1gN8Bazw= +github.com/manifoldco/promptui v0.7.0 h1:3l11YT8tm9MnwGFQ4kETwkzpAwY2Jt9lCrumCUW4+z4= +github.com/manifoldco/promptui v0.7.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.0 h1:v2XXALHHh6zHfYTJ+cSkwtyffnaOyR1MXaA91mTrb8o= github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA= +github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= +github.com/mattn/go-sqlite3 v1.14.2 h1:A2EQLwjYf/hfYaM20FVjs1UewCTTFR7RmjEHkLjldIA= +github.com/mattn/go-sqlite3 v1.14.2/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s= github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= +github.com/microcosm-cc/bluemonday v1.0.3 h1:EjVH7OqbU219kdm8acbveoclh2zZFqPJTJw6VUlTLAQ= +github.com/microcosm-cc/bluemonday v1.0.3/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w= +github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg= +github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w= github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/nicksnyder/go-i18n v1.10.0 h1:5AzlPKvXBH4qBzmZ09Ua9Gipyruv6uApMcrNZdo96+Q= @@ -112,6 +151,10 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prologic/go-gopher v0.0.0-20191226035442-664dbdb49f44 h1:q5sit1FpzEt59aM2Fd2lSBKF+nxcY1o0StRCiJa/pWo= +github.com/prologic/go-gopher v0.0.0-20191226035442-664dbdb49f44/go.mod h1:a97DSBRiRljeRVd5CRZL5bYCIeeGjSEngGf+QMR2evA= +github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469 h1:rAbv2gekFbUcjhUkruwo0vMJ0JqhUgg9tz7t+bxHbN4= +github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469/go.mod h1:c61IFFAJw8ADWu54tti30Tj5VrBstVoTprmET35UEkY= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= @@ -122,22 +165,28 @@ github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PX github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/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/urfave/cli/v2 v2.2.0 h1:JTTnM6wKzdA0Jqodd966MVj4vWbbquZykeX1sKbe2C4= +github.com/urfave/cli/v2 v2.2.0/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-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= @@ -146,10 +195,12 @@ 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/import v0.2.1 h1:3k+bDNCyqaWdZinyUZtEO4je3mR6fr/nE4ozTh9/9Wg= +github.com/writeas/import v0.2.1/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM= github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 h1:baEp0631C8sT2r/hqwypIw2snCFZa6h7U6TojoLHu/c= github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219/go.mod h1:NyM35ayknT7lzO6O/1JpfgGyv+0W9Z9q7aE0J8bXxfQ= github.com/writeas/nerds v1.0.0 h1:ZzRcCN+Sr3MWID7o/x1cr1ZbLvdpej9Y1/Ho+JKlqxo= @@ -159,10 +210,14 @@ 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= github.com/writeas/web-core v1.2.0/go.mod h1:vTYajviuNBAxjctPp2NUYdgjofywVkxUGpeaERF3SfI= +github.com/writeas/web-core v1.2.1-0.20200813161734-68a680d1b03c h1:/aPb8WKtC+Ga/xUEcME0iX3VKBeeJ02kXCaROaZ21SE= +github.com/writeas/web-core v1.2.1-0.20200813161734-68a680d1b03c/go.mod h1:vTYajviuNBAxjctPp2NUYdgjofywVkxUGpeaERF3SfI= github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss= github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg= golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo= @@ -171,19 +226,30 @@ golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200109152110-61a87790db17 h1:nVJ3guKA9qdkEQ3TUdXI9QSINo2CUPM/cySEvw2w8I0= golang.org/x/crypto v0.0.0-20200109152110-61a87790db17/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 h1:rJm0LuqUjoDhSk2zO9ISMSToQxGz7Os2jRiOL8AWu4c= golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200707034311-ab3426394381 h1:VXak5I6aEWmAXeQjA+QSZzlgNrpq9mjcfDemuexIKsU= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20181122213734-04b5d21e00f1/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -196,11 +262,14 @@ 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/ini.v1 v1.57.0 h1:9unxIsFcTt4I55uWluz+UmL95q4kdJ0buvQ1ZIqVQww= +gopkg.in/ini.v1 v1.57.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= -src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b h1:rPAdjgXks4ToezTjygsnKZroxKVnA1L35DSpsJXPtfc= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU= diff --git a/gopher.go b/gopher.go new file mode 100644 index 0000000..30391f1 --- /dev/null +++ b/gopher.go @@ -0,0 +1,146 @@ +/* + * Copyright © 2020 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package writefreely + +import ( + "bytes" + "fmt" + "io" + "strings" + + "github.com/prologic/go-gopher" + "github.com/writeas/web-core/log" +) + +func initGopher(apper Apper) { + handler := NewWFHandler(apper) + + gopher.HandleFunc("/", handler.Gopher(handleGopher)) + log.Info("Serving on gopher://localhost:%d", apper.App().Config().Server.GopherPort) + gopher.ListenAndServe(fmt.Sprintf(":%d", apper.App().Config().Server.GopherPort), nil) +} + +func handleGopher(app *App, w gopher.ResponseWriter, r *gopher.Request) error { + parts := strings.Split(r.Selector, "/") + if app.cfg.App.SingleUser { + if parts[1] != "" { + return handleGopherCollectionPost(app, w, r) + } + return handleGopherCollection(app, w, r) + } + + // Show all public collections (a gopher Reader view, essentially) + if len(parts) == 3 { + return handleGopherCollection(app, w, r) + } + + w.WriteInfo(fmt.Sprintf("Welcome to %s", app.cfg.App.SiteName)) + + colls, err := app.db.GetPublicCollections(app.cfg.App.Host) + if err != nil { + return err + } + + for _, c := range *colls { + w.WriteItem(&gopher.Item{ + Type: gopher.DIRECTORY, + Description: c.DisplayTitle(), + Selector: "/" + c.Alias + "/", + }) + } + return w.End() +} + +func handleGopherCollection(app *App, w gopher.ResponseWriter, r *gopher.Request) error { + var collAlias, slug string + var c *Collection + var err error + var baseSel = "/" + + parts := strings.Split(r.Selector, "/") + if app.cfg.App.SingleUser { + // sanity check + slug = parts[1] + if slug != "" { + return handleGopherCollectionPost(app, w, r) + } + + c, err = app.db.GetCollectionByID(1) + if err != nil { + return err + } + } else { + collAlias = parts[1] + slug = parts[2] + if slug != "" { + return handleGopherCollectionPost(app, w, r) + } + + c, err = app.db.GetCollection(collAlias) + if err != nil { + return err + } + baseSel = "/" + c.Alias + "/" + } + c.hostName = app.cfg.App.Host + + posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false) + if err != nil { + return err + } + + for _, p := range *posts { + w.WriteItem(&gopher.Item{ + Type: gopher.FILE, + Description: p.CreatedDate() + " - " + p.DisplayTitle(), + Selector: baseSel + p.Slug.String, + }) + } + return w.End() +} + +func handleGopherCollectionPost(app *App, w gopher.ResponseWriter, r *gopher.Request) error { + var collAlias, slug string + var c *Collection + var err error + + parts := strings.Split(r.Selector, "/") + if app.cfg.App.SingleUser { + slug = parts[1] + c, err = app.db.GetCollectionByID(1) + if err != nil { + return err + } + } else { + collAlias = parts[1] + slug = parts[2] + c, err = app.db.GetCollection(collAlias) + if err != nil { + return err + } + } + c.hostName = app.cfg.App.Host + + p, err := app.db.GetPost(slug, c.ID) + if err != nil { + return err + } + + b := bytes.Buffer{} + if p.Title.String != "" { + b.WriteString(p.Title.String + "\n") + } + b.WriteString(p.DisplayDate + "\n\n") + b.WriteString(p.Content) + io.Copy(w, &b) + + return w.End() +} diff --git a/handle.go b/handle.go index 0fcc483..5e15137 100644 --- a/handle.go +++ b/handle.go @@ -21,6 +21,7 @@ import ( "time" "github.com/gorilla/sessions" + "github.com/prologic/go-gopher" "github.com/writeas/impart" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/config" @@ -64,6 +65,7 @@ func UserLevelReader(cfg *config.Config) UserLevel { type ( handlerFunc func(app *App, w http.ResponseWriter, r *http.Request) error + gopherFunc func(app *App, w gopher.ResponseWriter, r *gopher.Request) error userHandlerFunc func(app *App, u *User, w http.ResponseWriter, r *http.Request) error userApperHandlerFunc func(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error dataHandlerFunc func(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) @@ -83,6 +85,7 @@ type ErrorPages struct { NotFound *template.Template Gone *template.Template InternalServerError *template.Template + UnavailableError *template.Template Blank *template.Template } @@ -94,6 +97,7 @@ func NewHandler(apper Apper) *Handler { NotFound: template.Must(template.New("").Parse("{{define \"base\"}}404

Not found.

{{end}}")), Gone: template.Must(template.New("").Parse("{{define \"base\"}}410

Gone.

{{end}}")), InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}500

Internal server error.

{{end}}")), + UnavailableError: template.Must(template.New("").Parse("{{define \"base\"}}503

Service is temporarily unavailable.

{{end}}")), Blank: template.Must(template.New("").Parse("{{define \"base\"}}{{.Title}}

{{.Content}}

{{end}}")), }, sessionStore: apper.App().SessionStore(), @@ -111,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 @@ -596,6 +601,9 @@ func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc { log.Info(h.app.ReqLog(r, status, time.Since(start))) }() + // Allow any origin, as public endpoints are handled in here + w.Header().Set("Access-Control-Allow-Origin", "*"); + if h.app.App().cfg.App.Private { // This instance is private, so ensure it's being accessed by a valid user // Check if authenticated with an access token @@ -763,6 +771,10 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er log.Info("handleHTTPErorr internal error render") 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 @@ -891,8 +903,33 @@ func (h *Handler) LogHandlerFunc(f http.HandlerFunc) http.HandlerFunc { } } +func (h *Handler) Gopher(f gopherFunc) gopher.HandlerFunc { + return func(w gopher.ResponseWriter, r *gopher.Request) { + defer func() { + if e := recover(); e != nil { + log.Error("%s: %s", e, debug.Stack()) + w.WriteError("An internal error occurred") + } + log.Info("gopher: %s", r.Selector) + }() + + err := f(h.app.App(), w, r) + if err != nil { + log.Error("failed: %s", err) + w.WriteError("the page failed for some reason (see logs)") + } + } +} + func sendRedirect(w http.ResponseWriter, code int, location string) int { w.Header().Set("Location", location) w.WriteHeader(code) return code } + +func cacheControl(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=604800, immutable") + next.ServeHTTP(w, r) + }) +} diff --git a/invites.go b/invites.go index d5d024a..4e3eff4 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") } @@ -158,18 +170,23 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error { p := struct { page.StaticPage + *OAuthButtons Error string Flashes []template.HTML Invite string }{ - StaticPage: pageForReq(app, r), - Invite: inviteCode, + StaticPage: pageForReq(app, r), + OAuthButtons: NewOAuthButtons(app.cfg), + Invite: inviteCode, } 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/admin.less b/less/admin.less index d9d659e..86dc9ff 100644 --- a/less/admin.less +++ b/less/admin.less @@ -32,6 +32,19 @@ nav#admin { display: flex; justify-content: center; + &:not(.pages) { + display: block; + margin: 0.5em 0; + a { + margin-left: 0; + .rounded(.25em); + + &+a { + margin-left: 0.5em; + } + } + } + a { color: #333; font-family: @sansFont; diff --git a/less/app.less b/less/app.less index e28e1ad..01db3fb 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 "resources"; @import "lib/elements"; diff --git a/less/core.less b/less/core.less index 8bee852..3265e9c 100644 --- a/less/core.less +++ b/less/core.less @@ -69,7 +69,7 @@ body { font-size: 1.5em; } h2 { - font-size: 1.17em; + font-size: 1.4em; } } @@ -627,6 +627,23 @@ table.classy { } } +article table { + border-spacing: 0; + border-collapse: collapse; + width: 100%; + th { + border-width: 1px 1px 2px 1px; + border-style: solid; + border-color: #ccc; + } + td { + border-width: 0 1px 1px 1px; + border-style: solid; + border-color: #ccc; + padding: .25rem .5rem; + } +} + body#collection article, body#subpage article { padding-top: 0; padding-bottom: 0; @@ -714,6 +731,18 @@ input, button, select.inputform, textarea.inputform, a.btn { } } +.btn.pager { + border: 1px solid @lightNavBorder; + font-size: .86em; + padding: .5em 1em; + white-space: nowrap; + font-family: @sansFont; + &:hover { + text-decoration: none; + background: @lightNavBorder; + } +} + div.flat-select { display: inline-block; position: relative; @@ -936,7 +965,12 @@ footer.contain-me { } ul { &.collections { + padding-left: 0; margin-left: 0; + h3 { + margin-top: 0; + font-weight: normal; + } li { &.collection { a.title { @@ -1066,7 +1100,8 @@ body#pad-sub #posts, .atoms { } .electron { font-weight: normal; - margin-left: 0.5em; + font-size: 0.86em; + margin-left: 0.75rem; } } h3, h4 { @@ -1216,7 +1251,7 @@ header { } } &.singleuser { - margin: 0.5em 0.25em; + margin: 0.5em 1em 0.5em 0.25em; nav#user-nav { nav > ul > li:first-child { img { @@ -1224,6 +1259,9 @@ header { } } } + .right-side { + padding-top: 0.5em; + } } .dash-nav { font-weight: bold; @@ -1518,3 +1556,26 @@ div.row { pre.code-block { overflow-x: auto; } + +#org-nav { + font-family: @sansFont; + font-size: 1.1em; + color: #888; + + em, strong { + color: #000; + } + &+h1 { + margin-top: 0.5em; + } + a:link, a:visited, a:hover { + color: @accent; + } + a:first-child { + margin-right: 0.25em; + } + a.coll-name { + font-weight: bold; + margin-left: 0.25em; + } +} \ No newline at end of file diff --git a/less/login.less b/less/login.less new file mode 100644 index 0000000..fefeb12 --- /dev/null +++ b/less/login.less @@ -0,0 +1,91 @@ +/* + * Copyright © 2020 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +.row.signinbtns { + justify-content: center; + font-size: 1em; + margin-top: 2em; + margin-bottom: 1em; + flex-wrap: wrap; + + .loginbtn { + height: 40px; + margin: 0.5em; + + &.btn { + box-sizing: border-box; + font-size: 17px; + white-space: nowrap; + + img { + height: 1.5em; + vertical-align: middle; + } + } + + &#writeas-login, &#slack-login { + img { + margin-top: -0.2em; + } + } + + &#gitlab-login { + background-color: #fc6d26; + border-color: #fc6d26; + &:hover { + background-color: darken(#fc6d26, 5%); + border-color: darken(#fc6d26, 5%); + } + } + + &#gitea-login { + background-color: #2ecc71; + border-color: #2ecc71; + &:hover { + background-color: #2cc26b; + border-color: #2cc26b; + } + } + + &#slack-login, &#gitlab-login, &#gitea-login, &#generic-oauth-login { + font-size: 0.86em; + font-family: @sansFont; + } + + &#slack-login, &#generic-oauth-login { + color: @lightTextColor; + background-color: @lightNavBG; + border-color: @lightNavBorder; + &:hover { + background-color: @lightNavHoverBG; + } + } + } +} + +.or { + text-align: center; + margin-bottom: 3.5em; + + p { + display: inline-block; + background-color: white; + padding: 0 1em; + } + + hr { + margin-top: -1.6em; + margin-bottom: 0; + } + + hr.short { + max-width: 30rem; + } +} \ No newline at end of file diff --git a/less/new-core.less b/less/new-core.less index d618042..c9e7a17 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; @@ -127,7 +127,6 @@ textarea { &.collection { a.title { font-size: 1.3em; - font-weight: bold; } } } diff --git a/less/pad.less b/less/pad.less index db38fe1..6cdd383 100644 --- a/less/pad.less +++ b/less/pad.less @@ -60,7 +60,7 @@ &:hover { background: @lightNavHoverBG; } - &:hover > ul { + &:hover > ul, &.open > ul { display: block; } &.selected { @@ -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 { @@ -433,6 +451,10 @@ body#pad { padding-left: 10%; padding-right: 10%; } + .alert { + left: 10%; + right: 10%; + } } } @media all and (min-width: 60em) { @@ -441,6 +463,10 @@ body#pad { padding-left: 15%; padding-right: 15%; } + .alert { + left: 15%; + right: 15%; + } } } @media all and (min-width: 70em) { @@ -449,6 +475,10 @@ body#pad { padding-left: 20%; padding-right: 20%; } + .alert { + left: 20%; + right: 20%; + } } } @media all and (min-width: 85em) { @@ -457,6 +487,10 @@ body#pad { padding-left: 25%; padding-right: 25%; } + .alert { + left: 25%; + right: 25%; + } } } @media all and (min-width: 105em) { @@ -465,6 +499,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 1a05280..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 { diff --git a/less/resources.less b/less/resources.less index 8421fee..c255166 100644 --- a/less/resources.less +++ b/less/resources.less @@ -8,4 +8,6 @@ @dangerCol: #e21d27; @errUrgentCol: #ecc63c; @proSelectedCol: #71D571; -@textLinkColor: rgb(0, 0, 238); \ No newline at end of file +@textLinkColor: rgb(0, 0, 238); + +@accent: #767676; \ No newline at end of file diff --git a/migrations/drivers.go b/migrations/drivers.go index 59fe16f..1399411 100644 --- a/migrations/drivers.go +++ b/migrations/drivers.go @@ -78,3 +78,10 @@ func (db *datastore) engine() string { } return " ENGINE = InnoDB" } + +func (db *datastore) after(colName string) string { + if db.driverName == driverSQLite { + return "" + } + return " AFTER " + colName +} diff --git a/migrations/migrations.go b/migrations/migrations.go index 41f036f..88897fd 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -61,7 +61,11 @@ 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 + New("support post signatures", supportPostSignatures), // V9 -> V10 } // CurrentVer returns the current migration version the application is on diff --git a/migrations/v10.go b/migrations/v10.go new file mode 100644 index 0000000..9c84a01 --- /dev/null +++ b/migrations/v10.go @@ -0,0 +1,33 @@ +/* + * Copyright © 2020 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func supportPostSignatures(db *datastore) error { + t, err := db.Begin() + if err != nil { + t.Rollback() + return err + } + + _, err = t.Exec(`ALTER TABLE collections ADD COLUMN post_signature ` + db.typeText() + db.collateMultiByte() + ` NULL` + db.after("script")) + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + + return nil +} 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..e3f65ef 100644 --- a/oauth.go +++ b/oauth.go @@ -1,22 +1,59 @@ +/* + * 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 + GiteaEnabled bool + GiteaDisplayName string + GenericEnabled bool + GenericDisplayName string +} + +// NewOAuthButtons creates a new OAuthButtons struct based on our app configuration. +func NewOAuthButtons(cfg *config.Config) *OAuthButtons { + return &OAuthButtons{ + SlackEnabled: cfg.SlackOauth.ClientID != "", + WriteAsEnabled: cfg.WriteAsOauth.ClientID != "", + GitLabEnabled: cfg.GitlabOauth.ClientID != "", + GitLabDisplayName: config.OrDefaultString(cfg.GitlabOauth.DisplayName, gitlabDisplayName), + GiteaEnabled: cfg.GiteaOauth.ClientID != "", + GiteaDisplayName: config.OrDefaultString(cfg.GiteaOauth.DisplayName, giteaDisplayName), + GenericEnabled: cfg.GenericOauth.ClientID != "", + GenericDisplayName: config.OrDefaultString(cfg.GenericOauth.DisplayName, genericOauthDisplayName), + } +} + // TokenResponse contains data returned when a token is created either // through a code exchange or using a refresh token. type TokenResponse struct { @@ -59,8 +96,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 +133,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 +199,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 +215,88 @@ func configureWriteAsOauth(parentHandler *Handler, r *mux.Router, app *App) { } } +func configureGitlabOauth(parentHandler *Handler, r *mux.Router, app *App) { + if app.Config().GitlabOauth.ClientID != "" { + callbackLocation := app.Config().App.Host + "/oauth/callback/gitlab" + + var callbackProxy *callbackProxyClient = nil + if app.Config().GitlabOauth.CallbackProxy != "" { + callbackProxy = &callbackProxyClient{ + server: app.Config().GitlabOauth.CallbackProxyAPI, + callbackLocation: app.Config().App.Host + "/oauth/callback/gitlab", + httpClient: config.DefaultHTTPClient(), + } + callbackLocation = app.Config().GitlabOauth.CallbackProxy + } + + address := config.OrDefaultString(app.Config().GitlabOauth.Host, gitlabHost) + oauthClient := gitlabOauthClient{ + ClientID: app.Config().GitlabOauth.ClientID, + ClientSecret: app.Config().GitlabOauth.ClientSecret, + ExchangeLocation: address + "/oauth/token", + InspectLocation: address + "/api/v4/user", + AuthLocation: address + "/oauth/authorize", + HttpClient: config.DefaultHTTPClient(), + CallbackLocation: callbackLocation, + } + configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy) + } +} + +func configureGenericOauth(parentHandler *Handler, r *mux.Router, app *App) { + if app.Config().GenericOauth.ClientID != "" { + callbackLocation := app.Config().App.Host + "/oauth/callback/generic" + + var callbackProxy *callbackProxyClient = nil + if app.Config().GenericOauth.CallbackProxy != "" { + callbackProxy = &callbackProxyClient{ + server: app.Config().GenericOauth.CallbackProxyAPI, + callbackLocation: app.Config().App.Host + "/oauth/callback/generic", + httpClient: config.DefaultHTTPClient(), + } + callbackLocation = app.Config().GenericOauth.CallbackProxy + } + + oauthClient := genericOauthClient{ + ClientID: app.Config().GenericOauth.ClientID, + ClientSecret: app.Config().GenericOauth.ClientSecret, + ExchangeLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.TokenEndpoint, + InspectLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.InspectEndpoint, + AuthLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.AuthEndpoint, + HttpClient: config.DefaultHTTPClient(), + CallbackLocation: callbackLocation, + } + configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy) + } +} + +func configureGiteaOauth(parentHandler *Handler, r *mux.Router, app *App) { + if app.Config().GiteaOauth.ClientID != "" { + callbackLocation := app.Config().App.Host + "/oauth/callback/gitea" + + var callbackProxy *callbackProxyClient = nil + if app.Config().GiteaOauth.CallbackProxy != "" { + callbackProxy = &callbackProxyClient{ + server: app.Config().GiteaOauth.CallbackProxyAPI, + callbackLocation: app.Config().App.Host + "/oauth/callback/gitea", + httpClient: config.DefaultHTTPClient(), + } + callbackLocation = app.Config().GiteaOauth.CallbackProxy + } + + oauthClient := giteaOauthClient{ + ClientID: app.Config().GiteaOauth.ClientID, + ClientSecret: app.Config().GiteaOauth.ClientSecret, + ExchangeLocation: app.Config().GiteaOauth.Host + "/login/oauth/access_token", + InspectLocation: app.Config().GiteaOauth.Host + "/api/v1/user", + AuthLocation: app.Config().GiteaOauth.Host + "/login/oauth/authorize", + HttpClient: config.DefaultHTTPClient(), + CallbackLocation: callbackLocation, + } + configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy) + } +} + func configureOauthRoutes(parentHandler *Handler, r *mux.Router, app *App, oauthClient oauthClient, callbackProxy *callbackProxyClient) { handler := &oauthHandler{ Config: app.Config(), @@ -185,7 +317,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()} @@ -194,10 +326,16 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http tokenResponse, err := h.oauthClient.exchangeOauthCode(ctx, code) if err != nil { log.Error("Unable to exchangeOauthCode: %s", err) + // TODO: show user friendly message if needed + // TODO: show NO message for cases like user pressing "Cancel" on authorize step + addSessionFlash(app, w, r, err.Error(), nil) + if attachUserID > 0 { + return impart.HTTPError{http.StatusFound, "/me/settings"} + } return impart.HTTPError{http.StatusInternalServerError, err.Error()} } - // 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 +349,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 +369,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 +407,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) @@ -251,7 +422,7 @@ func (r *callbackProxyClient) register(ctx context.Context, state string) error if err != nil { return err } - req.Header.Set("User-Agent", "writefreely") + req.Header.Set("User-Agent", ServerUserAgent("")) req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/x-www-form-urlencoded") diff --git a/oauth_generic.go b/oauth_generic.go new file mode 100644 index 0000000..ce65bca --- /dev/null +++ b/oauth_generic.go @@ -0,0 +1,114 @@ +package writefreely + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" +) + +type genericOauthClient struct { + ClientID string + ClientSecret string + AuthLocation string + ExchangeLocation string + InspectLocation string + CallbackLocation string + HttpClient HttpClient +} + +var _ oauthClient = genericOauthClient{} + +const ( + genericOauthDisplayName = "OAuth" +) + +func (c genericOauthClient) GetProvider() string { + return "generic" +} + +func (c genericOauthClient) GetClientID() string { + return c.ClientID +} + +func (c genericOauthClient) GetCallbackLocation() string { + return c.CallbackLocation +} + +func (c genericOauthClient) buildLoginURL(state string) (string, error) { + u, err := url.Parse(c.AuthLocation) + if err != nil { + return "", err + } + q := u.Query() + q.Set("client_id", c.ClientID) + q.Set("redirect_uri", c.CallbackLocation) + q.Set("response_type", "code") + q.Set("state", state) + q.Set("scope", "read_user") + u.RawQuery = q.Encode() + return u.String(), nil +} + +func (c genericOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) { + form := url.Values{} + form.Add("grant_type", "authorization_code") + form.Add("redirect_uri", c.CallbackLocation) + form.Add("scope", "read_user") + form.Add("code", code) + req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.WithContext(ctx) + req.Header.Set("User-Agent", ServerUserAgent("")) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(c.ClientID, c.ClientSecret) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.New("unable to exchange code for access token") + } + + var tokenResponse TokenResponse + if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil { + return nil, err + } + if tokenResponse.Error != "" { + return nil, errors.New(tokenResponse.Error) + } + return &tokenResponse, nil +} + +func (c genericOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) { + req, err := http.NewRequest("GET", c.InspectLocation, nil) + if err != nil { + return nil, err + } + req.WithContext(ctx) + req.Header.Set("User-Agent", ServerUserAgent("")) + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.New("unable to inspect access token") + } + + 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_gitea.go b/oauth_gitea.go new file mode 100644 index 0000000..a9b7741 --- /dev/null +++ b/oauth_gitea.go @@ -0,0 +1,114 @@ +package writefreely + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" +) + +type giteaOauthClient struct { + ClientID string + ClientSecret string + AuthLocation string + ExchangeLocation string + InspectLocation string + CallbackLocation string + HttpClient HttpClient +} + +var _ oauthClient = giteaOauthClient{} + +const ( + giteaDisplayName = "Gitea" +) + +func (c giteaOauthClient) GetProvider() string { + return "gitea" +} + +func (c giteaOauthClient) GetClientID() string { + return c.ClientID +} + +func (c giteaOauthClient) GetCallbackLocation() string { + return c.CallbackLocation +} + +func (c giteaOauthClient) buildLoginURL(state string) (string, error) { + u, err := url.Parse(c.AuthLocation) + if err != nil { + return "", err + } + q := u.Query() + q.Set("client_id", c.ClientID) + q.Set("redirect_uri", c.CallbackLocation) + q.Set("response_type", "code") + q.Set("state", state) + // q.Set("scope", "read_user") + u.RawQuery = q.Encode() + return u.String(), nil +} + +func (c giteaOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) { + form := url.Values{} + form.Add("grant_type", "authorization_code") + form.Add("redirect_uri", c.CallbackLocation) + // form.Add("scope", "read_user") + form.Add("code", code) + req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.WithContext(ctx) + req.Header.Set("User-Agent", ServerUserAgent("")) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(c.ClientID, c.ClientSecret) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.New("unable to exchange code for access token") + } + + var tokenResponse TokenResponse + if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil { + return nil, err + } + if tokenResponse.Error != "" { + return nil, errors.New(tokenResponse.Error) + } + return &tokenResponse, nil +} + +func (c giteaOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) { + req, err := http.NewRequest("GET", c.InspectLocation, nil) + if err != nil { + return nil, err + } + req.WithContext(ctx) + req.Header.Set("User-Agent", ServerUserAgent("")) + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.New("unable to inspect access token") + } + + var inspectResponse InspectResponse + if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &inspectResponse); err != nil { + return nil, err + } + if inspectResponse.Error != "" { + return nil, errors.New(inspectResponse.Error) + } + return &inspectResponse, nil +} diff --git a/oauth_gitlab.go b/oauth_gitlab.go new file mode 100644 index 0000000..ad919e4 --- /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", ServerUserAgent("")) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(c.ClientID, c.ClientSecret) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.New("unable to exchange code for access token") + } + + var tokenResponse TokenResponse + if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil { + return nil, err + } + if tokenResponse.Error != "" { + return nil, errors.New(tokenResponse.Error) + } + return &tokenResponse, nil +} + +func (c gitlabOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) { + req, err := http.NewRequest("GET", c.InspectLocation, nil) + if err != nil { + return nil, err + } + req.WithContext(ctx) + req.Header.Set("User-Agent", ServerUserAgent("")) + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.New("unable to inspect access token") + } + + var inspectResponse InspectResponse + if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &inspectResponse); err != nil { + return nil, err + } + if inspectResponse.Error != "" { + return nil, errors.New(inspectResponse.Error) + } + return &inspectResponse, nil +} 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..bad3775 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" @@ -113,7 +111,7 @@ func (c slackOauthClient) exchangeOauthCode(ctx context.Context, code string) (* return nil, err } req.WithContext(ctx) - req.Header.Set("User-Agent", "writefreely") + req.Header.Set("User-Agent", ServerUserAgent("")) req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.SetBasicAuth(c.ClientID, c.ClientSecret) @@ -142,7 +140,7 @@ func (c slackOauthClient) inspectOauthAccessToken(ctx context.Context, accessTok return nil, err } req.WithContext(ctx) - req.Header.Set("User-Agent", "writefreely") + req.Header.Set("User-Agent", ServerUserAgent("")) req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+accessToken) @@ -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..f454f1a 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") }, } @@ -246,7 +244,7 @@ func TestViewOauthCallback(t *testing.T) { req, err := http.NewRequest("GET", "/oauth/callback", nil) assert.NoError(t, err) rr := httptest.NewRecorder() - err = h.viewOauthCallback(nil, rr, req) + err = h.viewOauthCallback(&App{cfg: app.Config(), sessionStore: app.SessionStore()}, rr, req) assert.NoError(t, err) assert.Equal(t, http.StatusTemporaryRedirect, rr.Code) }) diff --git a/oauth_writeas.go b/oauth_writeas.go index 6251a16..e58f6e9 100644 --- a/oauth_writeas.go +++ b/oauth_writeas.go @@ -62,7 +62,7 @@ func (c writeAsOauthClient) exchangeOauthCode(ctx context.Context, code string) return nil, err } req.WithContext(ctx) - req.Header.Set("User-Agent", "writefreely") + req.Header.Set("User-Agent", ServerUserAgent("")) req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.SetBasicAuth(c.ClientID, c.ClientSecret) @@ -91,7 +91,7 @@ func (c writeAsOauthClient) inspectOauthAccessToken(ctx context.Context, accessT return nil, err } req.WithContext(ctx) - req.Header.Set("User-Agent", "writefreely") + req.Header.Set("User-Agent", ServerUserAgent("")) req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+accessToken) 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"}}Temporarily Unavailable — {{.SiteMetaName}}{{end}} +{{define "content"}} +
+

The words aren't coming to me. 🗅

+

We couldn't serve this page due to high server load. This should only be temporary.

+
+{{end}} diff --git a/pages/landing.tmpl b/pages/landing.tmpl index d3867a9..2131b40 100644 --- a/pages/landing.tmpl +++ b/pages/landing.tmpl @@ -60,6 +60,9 @@ form dd { margin-top: 0; max-width: 8em; } +.or { + margin-bottom: 2.5em !important; +} {{end}} {{define "content"}} @@ -73,6 +76,8 @@ form dd { {{ if .OpenRegistration }} + {{template "oauth-buttons" .}} + {{if not .DisablePasswordAuth}} {{if .Flashes}}{{end}} @@ -101,6 +106,7 @@ form dd { + {{end}} {{ else }}

Registration is currently closed.

You can always sign up on another instance.

diff --git a/pages/login.tmpl b/pages/login.tmpl index 345b171..f0a54eb 100644 --- a/pages/login.tmpl +++ b/pages/login.tmpl @@ -3,35 +3,6 @@ {{end}} {{define "content"}} @@ -42,22 +13,9 @@ hr.short { {{range .Flashes}}
  • {{.}}
  • {{end}} {{end}} - {{ if or .OauthSlack .OauthWriteAs }} -
    - {{ if .OauthSlack }} - Sign in with Slack - {{ end }} - {{ if .OauthWriteAs }} - Sign in with Write.as - {{ end }} -
    - -
    -

    or

    -
    -
    - {{ end }} + {{template "oauth-buttons" .}} +{{if not .DisablePasswordAuth}}


    @@ -65,13 +23,14 @@ hr.short {
    - {{if and (not .SingleUser) .OpenRegistration}}

    {{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}} {{end}} diff --git a/pages/signup-oauth.tmpl b/pages/signup-oauth.tmpl index ecf5db0..fcd70d2 100644 --- a/pages/signup-oauth.tmpl +++ b/pages/signup-oauth.tmpl @@ -1,6 +1,4 @@ -{{define "head"}}Log in — {{.SiteName}} - - +{{define "head"}}Finish Creating Account — {{.SiteName}}
    @@ -42,7 +49,7 @@ p.docs {
    Host -

    The address where your site lives.

    +

    The public address where users will access your site, starting with http:// or https://.

    {{.Config.Host}}
    @@ -56,24 +63,57 @@ p.docs {