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/account.go b/account.go index 56a4f84..ba013c2 100644 --- a/account.go +++ b/account.go @@ -49,6 +49,7 @@ type ( Separator template.HTML IsAdmin bool CanInvite bool + CollAlias string } ) @@ -86,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 @@ -299,24 +305,18 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error { p := &struct { page.StaticPage - To string - Message template.HTML - Flashes []template.HTML - LoginUsername string - OauthSlack bool - OauthWriteAs bool - OauthGitlab bool - GitlabDisplayName string + *OAuthButtons + To string + Message template.HTML + Flashes []template.HTML + LoginUsername string }{ - pageForReq(app, r), - r.FormValue("to"), - template.HTML(""), - []template.HTML{}, - getTempInfo(app, "login-user", r, w), - app.Config().SlackOauth.ClientID != "", - app.Config().WriteAsOauth.ClientID != "", - app.Config().GitlabOauth.ClientID != "", - config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName), + 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 != "" { @@ -391,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.") @@ -836,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 @@ -1015,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 { @@ -1045,13 +1052,15 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err 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 _, oauthAccount := range oauthAccounts { + for idx, oauthAccount := range oauthAccounts { switch oauthAccount.Provider { case "slack": enableOauthSlack = false @@ -1059,35 +1068,49 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err 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 || len(oauthAccounts) > 0 + displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || enableOauthGeneric || enableOauthGitea || len(oauthAccounts) > 0 obj := struct { *UserPage - Email string - HasPass bool - IsLogOut bool - Silenced bool - OauthSection bool - OauthAccounts []oauthAccountInfo - OauthSlack bool - OauthWriteAs bool - OauthGitLab bool - GitLabDisplayName string + 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(), - OauthSection: displayOauthSection, - OauthAccounts: oauthAccounts, - OauthSlack: enableOauthSlack, - OauthWriteAs: enableOauthWriteAs, - OauthGitLab: enableOauthGitLab, - GitLabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName), + 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) diff --git a/activitypub.go b/activitypub.go index 328284f..0e69075 100644 --- a/activitypub.go +++ b/activitypub.go @@ -494,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))) @@ -544,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) @@ -699,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) diff --git a/app.go b/app.go index 2ba43fc..2aed437 100644 --- a/app.go +++ b/app.go @@ -238,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 @@ -245,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, } @@ -890,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/config/config.go b/config/config.go index 18efd14..7b64e02 100644 --- a/config/config.go +++ b/config/config.go @@ -81,6 +81,15 @@ type ( 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"` @@ -89,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"` @@ -131,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 @@ -141,6 +166,8 @@ type ( 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"` } ) diff --git a/database.go b/database.go index 6f97d8c..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" @@ -2626,9 +2627,11 @@ func (db *datastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provi } type oauthAccountInfo struct { - Provider string - ClientID string - RemoteUserID string + Provider string + ClientID string + RemoteUserID string + DisplayName string + AllowDisconnect bool } func (db *datastore) GetOauthAccounts(ctx context.Context, userID int64) ([]oauthAccountInfo, error) { @@ -2691,6 +2694,17 @@ 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 diff --git a/errors.go b/errors.go index 579386b..cf52df1 100644 --- a/errors.go +++ b/errors.go @@ -52,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 a1c3c66..1d03956 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect github.com/gorilla/feeds v1.1.1 github.com/gorilla/mux v1.7.4 - github.com/gorilla/schema v1.1.0 + github.com/gorilla/schema v1.2.0 github.com/gorilla/sessions v1.2.0 github.com/guregu/null v3.5.0+incompatible github.com/hashicorp/go-multierror v1.1.0 @@ -22,9 +22,8 @@ require ( github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec github.com/lunixbochs/vtclean v1.0.0 // indirect github.com/manifoldco/promptui v0.7.0 - github.com/mattn/go-colorable v0.1.0 // indirect - github.com/mattn/go-sqlite3 v1.14.0 - github.com/microcosm-cc/bluemonday v1.0.3 + 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 @@ -47,7 +46,7 @@ require ( github.com/writeas/nerds v1.0.0 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-20200622213623-75b288015ac9 golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect diff --git a/go.sum b/go.sum index 3578123..90c1bdd 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,8 @@ 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= @@ -129,10 +131,14 @@ github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK86 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= @@ -210,6 +216,8 @@ 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= diff --git a/handle.go b/handle.go index 1b5470f..5e15137 100644 --- a/handle.go +++ b/handle.go @@ -601,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 @@ -923,3 +926,10 @@ func sendRedirect(w http.ResponseWriter, code int, location string) int { 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 10416b2..4e3eff4 100644 --- a/invites.go +++ b/invites.go @@ -170,14 +170,14 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error { p := struct { page.StaticPage + *OAuthButtons Error string Flashes []template.HTML Invite string - OAuth *OAuthButtons }{ - StaticPage: pageForReq(app, r), - Invite: inviteCode, - OAuth: NewOAuthButtons(app.cfg), + StaticPage: pageForReq(app, r), + OAuthButtons: NewOAuthButtons(app.cfg), + Invite: inviteCode, } if expired { 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/core.less b/less/core.less index c1cfad8..b085241 100644 --- a/less/core.less +++ b/less/core.less @@ -10,6 +10,8 @@ @proSelectedCol: #71D571; @textLinkColor: rgb(0, 0, 238); +@accent: #767676; + body { font-family: @serifFont; font-size-adjust: 0.5; @@ -81,7 +83,7 @@ body { font-size: 1.5em; } h2 { - font-size: 1.17em; + font-size: 1.4em; } } @@ -743,6 +745,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; @@ -965,7 +979,12 @@ footer.contain-me { } ul { &.collections { + padding-left: 0; margin-left: 0; + h3 { + margin-top: 0; + font-weight: normal; + } li { &.collection { a.title { @@ -1095,7 +1114,8 @@ body#pad-sub #posts, .atoms { } .electron { font-weight: normal; - margin-left: 0.5em; + font-size: 0.86em; + margin-left: 0.75rem; } } h3, h4 { @@ -1245,7 +1265,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 { @@ -1253,6 +1273,9 @@ header { } } } + .right-side { + padding-top: 0.5em; + } } .dash-nav { font-weight: bold; @@ -1547,3 +1570,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 index 473d26f..fefeb12 100644 --- a/less/login.less +++ b/less/login.less @@ -9,18 +9,64 @@ */ .row.signinbtns { - justify-content: space-evenly; + 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; + } - #writeas-login, #gitlab-login { - box-sizing: border-box; - font-size: 17px; + slack-login, generic-oauth-login { + color: @lightTextColor; + background-color: @lightNavBG; + border-color: @lightNavBorder; + &:hover { + background-color: @lightNavHoverBG; + } + } } } diff --git a/less/new-core.less b/less/new-core.less index 87d8158..c9e7a17 100644 --- a/less/new-core.less +++ b/less/new-core.less @@ -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 d3e4350..91c002d 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 { diff --git a/oauth.go b/oauth.go index b5c88aa..e3f65ef 100644 --- a/oauth.go +++ b/oauth.go @@ -30,19 +30,27 @@ import ( // OAuthButtons holds display information for different OAuth providers we support. type OAuthButtons struct { - SlackEnabled bool - WriteAsEnabled bool - GitLabEnabled bool - GitLabDisplayName string + 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), + 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), } } @@ -235,6 +243,60 @@ func configureGitlabOauth(parentHandler *Handler, r *mux.Router, app *App) { } } +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(), @@ -264,6 +326,12 @@ 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()} } @@ -354,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 index c9c74aa..ad919e4 100644 --- a/oauth_gitlab.go +++ b/oauth_gitlab.go @@ -63,7 +63,7 @@ func (c gitlabOauthClient) 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) @@ -92,7 +92,7 @@ func (c gitlabOauthClient) inspectOauthAccessToken(ctx context.Context, accessTo 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/oauth_slack.go b/oauth_slack.go index c881ab6..bad3775 100644 --- a/oauth_slack.go +++ b/oauth_slack.go @@ -111,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) @@ -140,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) diff --git a/oauth_test.go b/oauth_test.go index 96f65b2..f454f1a 100644 --- a/oauth_test.go +++ b/oauth_test.go @@ -244,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/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 {
Registration is currently closed.
You can always sign up on another instance.
diff --git a/pages/login.tmpl b/pages/login.tmpl index 5a338a4..f0a54eb 100644 --- a/pages/login.tmpl +++ b/pages/login.tmpl @@ -13,25 +13,9 @@ input{margin-bottom:0.5em;} {{range .Flashes}}or
-