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 @@
-
-
-
+
+
+
-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
-## Quick start
+Blog elements are localized in 20+ languages, and WriteFreely includes first-class support for non-Latin and right-to-left (RTL) script languages.
-WriteFreely has minimal requirements to get up and running — you only need to be able to run an executable.
+### Private by default
-> **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.
+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.
-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
+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 ❤️
-WriteFreely is available in these package repositories:
+[**Learn more on Write.as**](https://write.as/writefreely).
+
+## Quick start
+
+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!
+
+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.
+
+### Packages
+
+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").
- ChangeColumn("remote_user_id",
- dialect.
- Column(
- "remote_user_id",
- wf_db.ColumnTypeVarChar,
- wf_db.OptionalInt{Set: true, Value: 128,})).
AddColumn(dialect.
Column(
"provider",
wf_db.ColumnTypeVarChar,
- wf_db.OptionalInt{Set: true, Value: 24,})).
+ wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")),
+ dialect.
+ AlterTable("oauth_users").
AddColumn(dialect.
Column(
"client_id",
wf_db.ColumnTypeVarChar,
- wf_db.OptionalInt{Set: true, Value: 128,})).
+ wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")),
+ dialect.
+ AlterTable("oauth_users").
AddColumn(dialect.
Column(
"access_token",
wf_db.ColumnTypeVarChar,
- wf_db.OptionalInt{Set: true, Value: 512,})),
- dialect.DropIndex("remote_user_id", "oauth_users"),
- dialect.DropIndex("user_id", "oauth_users"),
- dialect.CreateUniqueIndex("oauth_users", "oauth_users", "user_id", "provider", "client_id"),
+ wf_db.OptionalInt{Set: true, Value: 512}).SetDefault("")),
+ dialect.CreateUniqueIndex("oauth_users_uk", "oauth_users", "user_id", "provider", "client_id"),
+ }
+
+ if dialect != wf_db.DialectSQLite {
+ // This updates the length of the `remote_user_id` column. It isn't needed for SQLite databases.
+ builders = append(builders, dialect.
+ AlterTable("oauth_users").
+ ChangeColumn("remote_user_id",
+ dialect.
+ Column(
+ "remote_user_id",
+ wf_db.ColumnTypeVarChar,
+ wf_db.OptionalInt{Set: true, Value: 128})))
}
+
for _, builder := range builders {
query, err := builder.ToSQL()
if err != nil {
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}}
{{range .Flashes}}{{.}} {{end}}
{{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 }}
-
- {{ end }}
- {{ if .OauthWriteAs }}
-
Sign in with Write.as
- {{ end }}
-
-
-
- {{ end }}
+ {{template "oauth-buttons" .}}
+{{if not .DisablePasswordAuth}}
- {{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,50 +63,35 @@ p.docs {
Landing Page
-
The page that logged-out visitors will see first. This should be a path, e.g. /read
+
The page that logged-out visitors will see first. This should be an absolute path like: /read
Open Registrations
- Whether or not registration is open to anyone who visits the site.
+ Allow anyone who visits the site to create an account.
-
- Minimum Username Length
- The minimum number of characters allowed in a username. (Recommended: 2 or more.)
-
-
-
-
-
- Maximum Blogs per User
- Keep things simple by setting this to 1 , unlimited by setting to 0 , or pick another amount.
-
-
-
-
-
- Federation
- Enable accounts on this site to propagate their posts via the ActivityPub protocol.
-
-
-
-
-
- Public Stats
- Publicly display the number of users and posts on your About page.
+
+ Allow invitations from...
+ Choose who is allowed to invite new people.
-
+
+
+ No one
+ Only Admins
+ All Users
+
+
Private Instance
- Make this instance accessible only to those with an account.
+ Limit site access to people with an account.
@@ -110,19 +102,6 @@ p.docs {
-
-
- Allow invitations from...
- Choose who on this instance can invite new people.
-
-
-
- No one
- Only Admins
- All Users
-
-
-
Default blog visibility
@@ -136,6 +115,34 @@ p.docs {
+
+
+ Maximum Blogs per User
+ Keep things simple by setting this to 1 , unlimited by setting to 0 , or pick another amount.
+
+
+
+
+
+ Federation
+ Enable accounts on this site to propagate their posts via the ActivityPub protocol.
+
+
+
+
+
+ Public Stats
+ Publicly display the number of users and posts on your About page.
+
+
+
+
+
+ Minimum Username Length
+ The minimum number of characters allowed in a username. (Recommended: 2 or more.)
+
+
+
diff --git a/templates/user/admin/users.tmpl b/templates/user/admin/users.tmpl
index 714fa24..4b2404e 100644
--- a/templates/user/admin/users.tmpl
+++ b/templates/user/admin/users.tmpl
@@ -26,7 +26,7 @@
{{end}}
-