Merge branch 'develop' of https://github.com/writeas/writefreely into fix-youtube-query-parameters

pull/364/head
Dami 5 years ago
commit 79715891fb
  1. 3
      .gitmodules
  2. 103
      account.go
  3. 8
      activitypub.go
  4. 12
      app.go
  5. 27
      config/config.go
  6. 20
      database.go
  7. 2
      errors.go
  8. 9
      go.mod
  9. 8
      go.sum
  10. 10
      handle.go
  11. 8
      invites.go
  12. 13
      less/admin.less
  13. 52
      less/core.less
  14. 56
      less/login.less
  15. 1
      less/new-core.less
  16. 2
      less/pad.less
  17. 86
      oauth.go
  18. 114
      oauth_generic.go
  19. 114
      oauth_gitea.go
  20. 4
      oauth_gitlab.go
  21. 4
      oauth_slack.go
  22. 2
      oauth_test.go
  23. 4
      oauth_writeas.go
  24. 6
      pages/landing.tmpl
  25. 35
      pages/login.tmpl
  26. 21
      pages/signup.tmpl
  27. 7
      parse/posts.go
  28. 3
      routes.go
  29. 38
      routes_test.go
  30. BIN
      static/img/mark/gitea.png
  31. BIN
      static/img/mark/writeas-white.png
  32. 1
      static/js/mathjax
  33. 1
      static/js/mathjax/tex-svg-full.js
  34. 34
      static/js/menu.js
  35. 31
      templates.go
  36. 3
      templates/base.tmpl
  37. 3
      templates/collection.tmpl
  38. 37
      templates/include/oauth.tmpl
  39. 27
      templates/include/post-render.tmpl
  40. 48
      templates/pad.tmpl
  41. 2
      templates/user/admin/users.tmpl
  42. 7
      templates/user/collection.tmpl
  43. 22
      templates/user/collections.tmpl
  44. 1
      templates/user/include/footer.tmpl
  45. 126
      templates/user/include/header.tmpl
  46. 16
      templates/user/include/nav.tmpl
  47. 71
      templates/user/settings.tmpl
  48. 9
      templates/user/stats.tmpl
  49. 1
      testdata/.gitignore
  50. 2
      testdata/config.ini
  51. 3
      testdata/static/style.css

3
.gitmodules vendored

@ -1,3 +0,0 @@
[submodule "static/js/mathjax"]
path = static/js/mathjax
url = https://github.com/mathjax/MathJax.git

@ -49,6 +49,7 @@ type (
Separator template.HTML Separator template.HTML
IsAdmin bool IsAdmin bool
CanInvite 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) { 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) reqJSON := IsJSON(r)
// Get params // Get params
@ -299,24 +305,18 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
p := &struct { p := &struct {
page.StaticPage page.StaticPage
To string *OAuthButtons
Message template.HTML To string
Flashes []template.HTML Message template.HTML
LoginUsername string Flashes []template.HTML
OauthSlack bool LoginUsername string
OauthWriteAs bool
OauthGitlab bool
GitlabDisplayName string
}{ }{
pageForReq(app, r), StaticPage: pageForReq(app, r),
r.FormValue("to"), OAuthButtons: NewOAuthButtons(app.Config()),
template.HTML(""), To: r.FormValue("to"),
[]template.HTML{}, Message: template.HTML(""),
getTempInfo(app, "login-user", r, w), Flashes: []template.HTML{},
app.Config().SlackOauth.ClientID != "", LoginUsername: getTempInfo(app, "login-user", r, w),
app.Config().WriteAsOauth.ClientID != "",
app.Config().GitlabOauth.ClientID != "",
config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName),
} }
if earlyError != "" { if earlyError != "" {
@ -391,6 +391,11 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error {
var err error var err error
var signin userCredentials var signin userCredentials
if app.cfg.App.DisablePasswordAuth {
err := ErrDisabledPasswordAuth
return err
}
// Log in with one-time token if one is given // Log in with one-time token if one is given
if oneTimeToken != "" { if oneTimeToken != "" {
log.Info("Login: Logging user in via token.") 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, Collection: c,
Silenced: silenced, Silenced: silenced,
} }
obj.UserPage.CollAlias = c.Alias
showUserPage(w, "collection", obj) showUserPage(w, "collection", obj)
return nil return nil
@ -1015,6 +1021,7 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
TopPosts: topPosts, TopPosts: topPosts,
Silenced: silenced, Silenced: silenced,
} }
obj.UserPage.CollAlias = c.Alias
if app.cfg.App.Federation { if app.cfg.App.Federation {
folls, err := app.db.GetAPFollowers(c) folls, err := app.db.GetAPFollowers(c)
if err != nil { 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 != "" enableOauthSlack := app.Config().SlackOauth.ClientID != ""
enableOauthWriteAs := app.Config().WriteAsOauth.ClientID != "" enableOauthWriteAs := app.Config().WriteAsOauth.ClientID != ""
enableOauthGitLab := app.Config().GitlabOauth.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) oauthAccounts, err := app.db.GetOauthAccounts(r.Context(), u.ID)
if err != nil { if err != nil {
log.Error("Unable to get oauth accounts for settings: %s", err) 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."} 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 { switch oauthAccount.Provider {
case "slack": case "slack":
enableOauthSlack = false enableOauthSlack = false
@ -1059,35 +1068,49 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err
enableOauthWriteAs = false enableOauthWriteAs = false
case "gitlab": case "gitlab":
enableOauthGitLab = false 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 { obj := struct {
*UserPage *UserPage
Email string Email string
HasPass bool HasPass bool
IsLogOut bool IsLogOut bool
Silenced bool Silenced bool
OauthSection bool OauthSection bool
OauthAccounts []oauthAccountInfo OauthAccounts []oauthAccountInfo
OauthSlack bool OauthSlack bool
OauthWriteAs bool OauthWriteAs bool
OauthGitLab bool OauthGitLab bool
GitLabDisplayName string GitLabDisplayName string
OauthGeneric bool
OauthGenericDisplayName string
OauthGitea bool
GiteaDisplayName string
}{ }{
UserPage: NewUserPage(app, r, u, "Account Settings", flashes), UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
Email: fullUser.EmailClear(app.keys), Email: fullUser.EmailClear(app.keys),
HasPass: passIsSet, HasPass: passIsSet,
IsLogOut: r.FormValue("logout") == "1", IsLogOut: r.FormValue("logout") == "1",
Silenced: fullUser.IsSilenced(), Silenced: fullUser.IsSilenced(),
OauthSection: displayOauthSection, OauthSection: displayOauthSection,
OauthAccounts: oauthAccounts, OauthAccounts: oauthAccounts,
OauthSlack: enableOauthSlack, OauthSlack: enableOauthSlack,
OauthWriteAs: enableOauthWriteAs, OauthWriteAs: enableOauthWriteAs,
OauthGitLab: enableOauthGitLab, OauthGitLab: enableOauthGitLab,
GitLabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName), 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) showUserPage(w, "settings", obj)

@ -494,7 +494,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m
r, _ := http.NewRequest("POST", url, bytes.NewBuffer(b)) r, _ := http.NewRequest("POST", url, bytes.NewBuffer(b))
r.Header.Add("Content-Type", "application/activity+json") 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 := sha256.New()
h.Write(b) h.Write(b)
r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil))) 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, _ := http.NewRequest("GET", url, nil)
r.Header.Add("Accept", "application/activity+json") 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 { if debugging {
dump, err := httputil.DumpRequestOut(r, true) 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 // I don't believe we'd ever have too many mentions in a single post that this
// could become a burden. // could become a burden.
remoteUser, err := getRemoteUser(app, tag.HRef) 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) err = makeActivityPost(app.cfg.App.Host, actor, remoteUser.Inbox, activity)
if err != nil { if err != nil {
log.Error("Couldn't post! %v", err) log.Error("Couldn't post! %v", err)

@ -238,6 +238,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
p := struct { p := struct {
page.StaticPage page.StaticPage
*OAuthButtons
Flashes []template.HTML Flashes []template.HTML
Banner template.HTML Banner template.HTML
Content template.HTML Content template.HTML
@ -245,6 +246,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
ForcedLanding bool ForcedLanding bool
}{ }{
StaticPage: pageForReq(app, r), StaticPage: pageForReq(app, r),
OAuthButtons: NewOAuthButtons(app.Config()),
ForcedLanding: forceLanding, ForcedLanding: forceLanding,
} }
@ -890,3 +892,13 @@ func adminInitDatabase(app *App) error {
log.Info("Done.") log.Info("Done.")
return nil 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 + ")"
}

@ -81,6 +81,15 @@ type (
CallbackProxyAPI string `ini:"callback_proxy_api"` 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 { SlackOauthCfg struct {
ClientID string `ini:"client_id"` ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"` ClientSecret string `ini:"client_secret"`
@ -89,6 +98,19 @@ type (
CallbackProxyAPI string `ini:"callback_proxy_api"` 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 holds values that affect how the application functions
AppCfg struct { AppCfg struct {
SiteName string `ini:"site_name"` SiteName string `ini:"site_name"`
@ -131,6 +153,9 @@ type (
// Check for Updates // Check for Updates
UpdateChecks bool `ini:"update_checks"` 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 // Config holds the complete configuration for running a writefreely instance
@ -141,6 +166,8 @@ type (
SlackOauth SlackOauthCfg `ini:"oauth.slack"` SlackOauth SlackOauthCfg `ini:"oauth.slack"`
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"` WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"` GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"`
GiteaOauth GiteaOauthCfg `ini:"oauth.gitea"`
GenericOauth GenericOauthCfg `ini:"oauth.generic"`
} }
) )

@ -14,6 +14,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"github.com/writeas/web-core/silobridge"
wf_db "github.com/writeas/writefreely/db" wf_db "github.com/writeas/writefreely/db"
"net/http" "net/http"
"strings" "strings"
@ -2626,9 +2627,11 @@ func (db *datastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provi
} }
type oauthAccountInfo struct { type oauthAccountInfo struct {
Provider string Provider string
ClientID string ClientID string
RemoteUserID string RemoteUserID string
DisplayName string
AllowDisconnect bool
} }
func (db *datastore) GetOauthAccounts(ctx context.Context, userID int64) ([]oauthAccountInfo, error) { 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) { func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, error) {
handle = strings.TrimLeft(handle, "@") handle = strings.TrimLeft(handle, "@")
actorIRI := "" 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) remoteUser, err := getRemoteUserFromHandle(app, handle)
if err != nil { if err != nil {
// can't find using handle in the table but the table may already have this user without // can't find using handle in the table but the table may already have this user without

@ -52,6 +52,8 @@ var (
ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."}
ErrUserSilenced = impart.HTTPError{http.StatusForbidden, "Account is silenced."} ErrUserSilenced = impart.HTTPError{http.StatusForbidden, "Account is silenced."}
ErrDisabledPasswordAuth = impart.HTTPError{http.StatusForbidden, "Password authentication is disabled."}
) )
// Post operation errors // Post operation errors

@ -12,7 +12,7 @@ require (
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
github.com/gorilla/feeds v1.1.1 github.com/gorilla/feeds v1.1.1
github.com/gorilla/mux v1.7.4 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/gorilla/sessions v1.2.0
github.com/guregu/null v3.5.0+incompatible github.com/guregu/null v3.5.0+incompatible
github.com/hashicorp/go-multierror v1.1.0 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/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
github.com/lunixbochs/vtclean v1.0.0 // indirect github.com/lunixbochs/vtclean v1.0.0 // indirect
github.com/manifoldco/promptui v0.7.0 github.com/manifoldco/promptui v0.7.0
github.com/mattn/go-colorable v0.1.0 // indirect github.com/mattn/go-sqlite3 v1.14.2
github.com/mattn/go-sqlite3 v1.14.0 github.com/microcosm-cc/bluemonday v1.0.4
github.com/microcosm-cc/bluemonday v1.0.3
github.com/mitchellh/go-wordwrap v1.0.0 github.com/mitchellh/go-wordwrap v1.0.0
github.com/nicksnyder/go-i18n v1.10.0 // indirect github.com/nicksnyder/go-i18n v1.10.0 // indirect
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
@ -47,7 +46,7 @@ require (
github.com/writeas/nerds v1.0.0 github.com/writeas/nerds v1.0.0
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320
github.com/writeas/slug v1.2.0 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 github.com/writefreely/go-nodeinfo v1.2.0
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect

@ -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.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 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= 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 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 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 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.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 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA=
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= 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 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.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 h1:EjVH7OqbU219kdm8acbveoclh2zZFqPJTJw6VUlTLAQ=
github.com/microcosm-cc/bluemonday v1.0.3/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w= 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 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4=
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
github.com/nicksnyder/go-i18n v1.10.0 h1:5AzlPKvXBH4qBzmZ09Ua9Gipyruv6uApMcrNZdo96+Q= 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/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 h1:CYqvBd+byi1cK4mCr1NZ6CjILuMOFmiFecv+OACcmG0=
github.com/writeas/web-core v1.2.0/go.mod h1:vTYajviuNBAxjctPp2NUYdgjofywVkxUGpeaERF3SfI= 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 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss=
github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg= 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= golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo=

@ -601,6 +601,9 @@ func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc {
log.Info(h.app.ReqLog(r, status, time.Since(start))) 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 { if h.app.App().cfg.App.Private {
// This instance is private, so ensure it's being accessed by a valid user // This instance is private, so ensure it's being accessed by a valid user
// Check if authenticated with an access token // Check if authenticated with an access token
@ -923,3 +926,10 @@ func sendRedirect(w http.ResponseWriter, code int, location string) int {
w.WriteHeader(code) w.WriteHeader(code)
return 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)
})
}

@ -170,14 +170,14 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
p := struct { p := struct {
page.StaticPage page.StaticPage
*OAuthButtons
Error string Error string
Flashes []template.HTML Flashes []template.HTML
Invite string Invite string
OAuth *OAuthButtons
}{ }{
StaticPage: pageForReq(app, r), StaticPage: pageForReq(app, r),
Invite: inviteCode, OAuthButtons: NewOAuthButtons(app.cfg),
OAuth: NewOAuthButtons(app.cfg), Invite: inviteCode,
} }
if expired { if expired {

@ -32,6 +32,19 @@ nav#admin {
display: flex; display: flex;
justify-content: center; justify-content: center;
&:not(.pages) {
display: block;
margin: 0.5em 0;
a {
margin-left: 0;
.rounded(.25em);
&+a {
margin-left: 0.5em;
}
}
}
a { a {
color: #333; color: #333;
font-family: @sansFont; font-family: @sansFont;

@ -10,6 +10,8 @@
@proSelectedCol: #71D571; @proSelectedCol: #71D571;
@textLinkColor: rgb(0, 0, 238); @textLinkColor: rgb(0, 0, 238);
@accent: #767676;
body { body {
font-family: @serifFont; font-family: @serifFont;
font-size-adjust: 0.5; font-size-adjust: 0.5;
@ -81,7 +83,7 @@ body {
font-size: 1.5em; font-size: 1.5em;
} }
h2 { 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 { div.flat-select {
display: inline-block; display: inline-block;
position: relative; position: relative;
@ -965,7 +979,12 @@ footer.contain-me {
} }
ul { ul {
&.collections { &.collections {
padding-left: 0;
margin-left: 0; margin-left: 0;
h3 {
margin-top: 0;
font-weight: normal;
}
li { li {
&.collection { &.collection {
a.title { a.title {
@ -1095,7 +1114,8 @@ body#pad-sub #posts, .atoms {
} }
.electron { .electron {
font-weight: normal; font-weight: normal;
margin-left: 0.5em; font-size: 0.86em;
margin-left: 0.75rem;
} }
} }
h3, h4 { h3, h4 {
@ -1245,7 +1265,7 @@ header {
} }
} }
&.singleuser { &.singleuser {
margin: 0.5em 0.25em; margin: 0.5em 1em 0.5em 0.25em;
nav#user-nav { nav#user-nav {
nav > ul > li:first-child { nav > ul > li:first-child {
img { img {
@ -1253,6 +1273,9 @@ header {
} }
} }
} }
.right-side {
padding-top: 0.5em;
}
} }
.dash-nav { .dash-nav {
font-weight: bold; font-weight: bold;
@ -1547,3 +1570,26 @@ div.row {
pre.code-block { pre.code-block {
overflow-x: auto; 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;
}
}

@ -9,18 +9,64 @@
*/ */
.row.signinbtns { .row.signinbtns {
justify-content: space-evenly; justify-content: center;
font-size: 1em; font-size: 1em;
margin-top: 2em; margin-top: 2em;
margin-bottom: 1em; margin-bottom: 1em;
flex-wrap: wrap;
.loginbtn { .loginbtn {
height: 40px; 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 { &#slack-login, &#generic-oauth-login {
box-sizing: border-box; color: @lightTextColor;
font-size: 17px; background-color: @lightNavBG;
border-color: @lightNavBorder;
&:hover {
background-color: @lightNavHoverBG;
}
}
} }
} }

@ -127,7 +127,6 @@ textarea {
&.collection { &.collection {
a.title { a.title {
font-size: 1.3em; font-size: 1.3em;
font-weight: bold;
} }
} }
} }

@ -60,7 +60,7 @@
&:hover { &:hover {
background: @lightNavHoverBG; background: @lightNavHoverBG;
} }
&:hover > ul { &:hover > ul, &.open > ul {
display: block; display: block;
} }
&.selected { &.selected {

@ -30,19 +30,27 @@ import (
// OAuthButtons holds display information for different OAuth providers we support. // OAuthButtons holds display information for different OAuth providers we support.
type OAuthButtons struct { type OAuthButtons struct {
SlackEnabled bool SlackEnabled bool
WriteAsEnabled bool WriteAsEnabled bool
GitLabEnabled bool GitLabEnabled bool
GitLabDisplayName string GitLabDisplayName string
GiteaEnabled bool
GiteaDisplayName string
GenericEnabled bool
GenericDisplayName string
} }
// NewOAuthButtons creates a new OAuthButtons struct based on our app configuration. // NewOAuthButtons creates a new OAuthButtons struct based on our app configuration.
func NewOAuthButtons(cfg *config.Config) *OAuthButtons { func NewOAuthButtons(cfg *config.Config) *OAuthButtons {
return &OAuthButtons{ return &OAuthButtons{
SlackEnabled: cfg.SlackOauth.ClientID != "", SlackEnabled: cfg.SlackOauth.ClientID != "",
WriteAsEnabled: cfg.WriteAsOauth.ClientID != "", WriteAsEnabled: cfg.WriteAsOauth.ClientID != "",
GitLabEnabled: cfg.GitlabOauth.ClientID != "", GitLabEnabled: cfg.GitlabOauth.ClientID != "",
GitLabDisplayName: config.OrDefaultString(cfg.GitlabOauth.DisplayName, gitlabDisplayName), 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) { func configureOauthRoutes(parentHandler *Handler, r *mux.Router, app *App, oauthClient oauthClient, callbackProxy *callbackProxyClient) {
handler := &oauthHandler{ handler := &oauthHandler{
Config: app.Config(), 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) tokenResponse, err := h.oauthClient.exchangeOauthCode(ctx, code)
if err != nil { if err != nil {
log.Error("Unable to exchangeOauthCode: %s", err) 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()} return impart.HTTPError{http.StatusInternalServerError, err.Error()}
} }
@ -354,7 +422,7 @@ func (r *callbackProxyClient) register(ctx context.Context, state string) error
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("User-Agent", "writefreely") req.Header.Set("User-Agent", ServerUserAgent(""))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

@ -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
}

@ -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
}

@ -63,7 +63,7 @@ func (c gitlabOauthClient) exchangeOauthCode(ctx context.Context, code string) (
return nil, err return nil, err
} }
req.WithContext(ctx) req.WithContext(ctx)
req.Header.Set("User-Agent", "writefreely") req.Header.Set("User-Agent", ServerUserAgent(""))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(c.ClientID, c.ClientSecret) req.SetBasicAuth(c.ClientID, c.ClientSecret)
@ -92,7 +92,7 @@ func (c gitlabOauthClient) inspectOauthAccessToken(ctx context.Context, accessTo
return nil, err return nil, err
} }
req.WithContext(ctx) req.WithContext(ctx)
req.Header.Set("User-Agent", "writefreely") req.Header.Set("User-Agent", ServerUserAgent(""))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Authorization", "Bearer "+accessToken)

@ -111,7 +111,7 @@ func (c slackOauthClient) exchangeOauthCode(ctx context.Context, code string) (*
return nil, err return nil, err
} }
req.WithContext(ctx) req.WithContext(ctx)
req.Header.Set("User-Agent", "writefreely") req.Header.Set("User-Agent", ServerUserAgent(""))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(c.ClientID, c.ClientSecret) req.SetBasicAuth(c.ClientID, c.ClientSecret)
@ -140,7 +140,7 @@ func (c slackOauthClient) inspectOauthAccessToken(ctx context.Context, accessTok
return nil, err return nil, err
} }
req.WithContext(ctx) req.WithContext(ctx)
req.Header.Set("User-Agent", "writefreely") req.Header.Set("User-Agent", ServerUserAgent(""))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Authorization", "Bearer "+accessToken)

@ -244,7 +244,7 @@ func TestViewOauthCallback(t *testing.T) {
req, err := http.NewRequest("GET", "/oauth/callback", nil) req, err := http.NewRequest("GET", "/oauth/callback", nil)
assert.NoError(t, err) assert.NoError(t, err)
rr := httptest.NewRecorder() 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.NoError(t, err)
assert.Equal(t, http.StatusTemporaryRedirect, rr.Code) assert.Equal(t, http.StatusTemporaryRedirect, rr.Code)
}) })

@ -62,7 +62,7 @@ func (c writeAsOauthClient) exchangeOauthCode(ctx context.Context, code string)
return nil, err return nil, err
} }
req.WithContext(ctx) req.WithContext(ctx)
req.Header.Set("User-Agent", "writefreely") req.Header.Set("User-Agent", ServerUserAgent(""))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(c.ClientID, c.ClientSecret) req.SetBasicAuth(c.ClientID, c.ClientSecret)
@ -91,7 +91,7 @@ func (c writeAsOauthClient) inspectOauthAccessToken(ctx context.Context, accessT
return nil, err return nil, err
} }
req.WithContext(ctx) req.WithContext(ctx)
req.Header.Set("User-Agent", "writefreely") req.Header.Set("User-Agent", ServerUserAgent(""))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Authorization", "Bearer "+accessToken)

@ -60,6 +60,9 @@ form dd {
margin-top: 0; margin-top: 0;
max-width: 8em; max-width: 8em;
} }
.or {
margin-bottom: 2.5em !important;
}
</style> </style>
{{end}} {{end}}
{{define "content"}} {{define "content"}}
@ -73,6 +76,8 @@ form dd {
<div{{if not .OpenRegistration}} style="padding: 2em 0;"{{end}}> <div{{if not .OpenRegistration}} style="padding: 2em 0;"{{end}}>
{{ if .OpenRegistration }} {{ if .OpenRegistration }}
{{template "oauth-buttons" .}}
{{if not .DisablePasswordAuth}}
{{if .Flashes}}<ul class="errors"> {{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}} {{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}} </ul>{{end}}
@ -101,6 +106,7 @@ form dd {
</dl> </dl>
</form> </form>
</div> </div>
{{end}}
{{ else }} {{ else }}
<p style="font-size: 1.3em; margin: 1rem 0;">Registration is currently closed.</p> <p style="font-size: 1.3em; margin: 1rem 0;">Registration is currently closed.</p>
<p>You can always sign up on <a href="https://writefreely.org/instances">another instance</a>.</p> <p>You can always sign up on <a href="https://writefreely.org/instances">another instance</a>.</p>

@ -13,25 +13,9 @@ input{margin-bottom:0.5em;}
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}} {{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}} </ul>{{end}}
{{ if or .OauthSlack .OauthWriteAs .OauthGitlab }} {{template "oauth-buttons" .}}
<div class="row content-container signinbtns">
{{ if .OauthSlack }}
<a class="loginbtn" href="/oauth/slack"><img alt="Sign in with Slack" height="40" width="172" src="/img/sign_in_with_slack.png" srcset="/img/sign_in_with_slack.png 1x, /img/sign_in_with_slack@2x.png 2x" /></a>
{{ end }}
{{ if .OauthWriteAs }}
<a class="btn cta loginbtn" id="writeas-login" href="/oauth/write.as">Sign in with <strong>Write.as</strong></a>
{{ end }}
{{ if .OauthGitlab }}
<a class="btn cta loginbtn" id="gitlab-login" href="/oauth/gitlab">Sign in with <strong>{{.GitlabDisplayName}}</strong></a>
{{ end }}
</div>
<div class="or">
<p>or</p>
<hr class="short" />
</div>
{{ end }}
{{if not .DisablePasswordAuth}}
<form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()"> <form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
<input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br /> <input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br />
<input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br /> <input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br />
@ -41,11 +25,12 @@ input{margin-bottom:0.5em;}
{{if and (not .SingleUser) .OpenRegistration}}<p style="text-align:center;font-size:0.9em;margin:3em auto;max-width:26em;">{{if .Message}}{{.Message}}{{else}}<em>No account yet?</em> <a href="{{.SignupPath}}">Sign up</a> to start a blog.{{end}}</p>{{end}} {{if and (not .SingleUser) .OpenRegistration}}<p style="text-align:center;font-size:0.9em;margin:3em auto;max-width:26em;">{{if .Message}}{{.Message}}{{else}}<em>No account yet?</em> <a href="{{.SignupPath}}">Sign up</a> to start a blog.{{end}}</p>{{end}}
<script type="text/javascript"> <script type="text/javascript">
function disableSubmit() { function disableSubmit() {
var $btn = document.getElementById("btn-login"); var $btn = document.getElementById("btn-login");
$btn.value = "Logging in..."; $btn.value = "Logging in...";
$btn.disabled = true; $btn.disabled = true;
} }
</script> </script>
{{end}}
{{end}} {{end}}

@ -70,25 +70,9 @@ form dd {
</ul>{{end}} </ul>{{end}}
<div id="billing"> <div id="billing">
{{ if or .OAuth.SlackEnabled .OAuth.WriteAsEnabled .OAuth.GitLabEnabled }} {{template "oauth-buttons" .}}
<div class="row content-container signinbtns">
{{ if .OAuth.SlackEnabled }}
<a class="loginbtn" href="/oauth/slack{{if .Invite}}?invite_code={{.Invite}}{{end}}"><img alt="Sign in with Slack" height="40" width="172" src="/img/sign_in_with_slack.png" srcset="/img/sign_in_with_slack.png 1x, /img/sign_in_with_slack@2x.png 2x" /></a>
{{ end }}
{{ if .OAuth.WriteAsEnabled }}
<a class="btn cta loginbtn" id="writeas-login" href="/oauth/write.as{{if .Invite}}?invite_code={{.Invite}}{{end}}">Sign in with <strong>Write.as</strong></a>
{{ end }}
{{ if .OAuth.GitLabEnabled }}
<a class="btn cta loginbtn" id="gitlab-login" href="/oauth/gitlab{{if .Invite}}?invite_code={{.Invite}}{{end}}">Sign in with <strong>{{.OAuth.GitLabDisplayName}}</strong></a>
{{ end }}
</div>
<div class="or">
<p>or</p>
<hr class="short" />
</div>
{{ end }}
{{if not .DisablePasswordAuth}}
<form action="/auth/signup" method="POST" id="signup-form" onsubmit="return signup()"> <form action="/auth/signup" method="POST" id="signup-form" onsubmit="return signup()">
<input type="hidden" name="invite_code" value="{{.Invite}}" /> <input type="hidden" name="invite_code" value="{{.Invite}}" />
<dl class="billing"> <dl class="billing">
@ -112,6 +96,7 @@ form dd {
</dt> </dt>
</dl> </dl>
</form> </form>
{{end}}
</div> </div>
{{ end }} {{ end }}
</div> </div>

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018 A Bunch Tell LLC. * Copyright © 2018-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -57,6 +57,11 @@ func PostLede(t string, includePunc bool) string {
c := []rune(t) c := []rune(t)
t = string(c[:punc+iAdj]) t = string(c[:punc+iAdj])
} }
punc = stringmanip.IndexRune(t, '?')
if punc > -1 {
c := []rune(t)
t = string(c[:punc+iAdj])
}
return t return t
} }

@ -26,6 +26,7 @@ import (
func (app *App) InitStaticRoutes(r *mux.Router) { func (app *App) InitStaticRoutes(r *mux.Router) {
// Handle static files // Handle static files
fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir))) fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir)))
fs = cacheControl(fs)
app.shttp = http.NewServeMux() app.shttp = http.NewServeMux()
app.shttp.Handle("/", fs) app.shttp.Handle("/", fs)
r.PathPrefix("/").Handler(fs) r.PathPrefix("/").Handler(fs)
@ -76,6 +77,8 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
configureSlackOauth(handler, write, apper.App()) configureSlackOauth(handler, write, apper.App())
configureWriteAsOauth(handler, write, apper.App()) configureWriteAsOauth(handler, write, apper.App())
configureGitlabOauth(handler, write, apper.App()) configureGitlabOauth(handler, write, apper.App())
configureGenericOauth(handler, write, apper.App())
configureGiteaOauth(handler, write, apper.App())
// Set up dyamic page handlers // Set up dyamic page handlers
// Handle auth // Handle auth

@ -0,0 +1,38 @@
package writefreely
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/mux"
)
func TestCacheControlForStaticFiles(t *testing.T) {
app := NewApp("testdata/config.ini")
if err := app.LoadConfig(); err != nil {
t.Fatalf("Could not create an app; %v", err)
}
router := mux.NewRouter()
app.InitStaticRoutes(router)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/style.css", nil)
router.ServeHTTP(rec, req)
if code := rec.Result().StatusCode; code != http.StatusOK {
t.Fatalf("Could not get /style.css, got HTTP status %d", code)
}
actual := rec.Result().Header.Get("Cache-Control")
expectedDirectives := []string{
"public",
"max-age",
"immutable",
}
for _, expected := range expectedDirectives {
if !strings.Contains(actual, expected) {
t.Errorf("Expected Cache-Control header to contain '%s', but was '%s'", expected, actual)
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@ -1 +0,0 @@
Subproject commit 419b0a6eee7eefc0f85e47f7d4f8227ec28b8e57

File diff suppressed because one or more lines are too long

@ -0,0 +1,34 @@
var menuItems = document.querySelectorAll('li.has-submenu');
var menuTimer;
function closeMenu($menu) {
$menu.querySelector('a').setAttribute('aria-expanded', "false");
$menu.className = "has-submenu";
}
Array.prototype.forEach.call(menuItems, function(el, i){
el.addEventListener("mouseover", function(event){
let $menu = document.querySelectorAll(".has-submenu.open");
if ($menu.length > 0) {
closeMenu($menu[0]);
}
this.className = "has-submenu open";
this.querySelector('a').setAttribute('aria-expanded', "true");
clearTimeout(menuTimer);
});
el.addEventListener("mouseout", function(event){
menuTimer = setTimeout(function(event){
let $menu = document.querySelector(".has-submenu.open");
closeMenu($menu);
}, 500);
});
el.querySelector('a').addEventListener("click", function(event){
if (this.parentNode.className == "has-submenu") {
this.parentNode.className = "has-submenu open";
this.setAttribute('aria-expanded', "true");
} else {
this.parentNode.className = "has-submenu";
this.setAttribute('aria-expanded', "false");
}
event.preventDefault();
return false;
});
});

@ -11,6 +11,7 @@
package writefreely package writefreely
import ( import (
"errors"
"html/template" "html/template"
"io" "io"
"io/ioutil" "io/ioutil"
@ -38,6 +39,9 @@ var (
"localhtml": localHTML, "localhtml": localHTML,
"tolower": strings.ToLower, "tolower": strings.ToLower,
"title": strings.Title, "title": strings.Title,
"hasPrefix": strings.HasPrefix,
"hasSuffix": strings.HasSuffix,
"dict": dict,
} }
) )
@ -85,12 +89,18 @@ func initPage(parentDir, path, key string) {
log.Info(" [%s] %s", key, path) log.Info(" [%s] %s", key, path)
} }
pages[key] = template.Must(template.New("").Funcs(funcMap).ParseFiles( files := []string{
path, path,
filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"), filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"),
filepath.Join(parentDir, templatesDir, "base.tmpl"), filepath.Join(parentDir, templatesDir, "base.tmpl"),
filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"), filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"),
)) }
if key == "login.tmpl" || key == "landing.tmpl" || key == "signup.tmpl" {
files = append(files, filepath.Join(parentDir, templatesDir, "include", "oauth.tmpl"))
}
pages[key] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...))
} }
func initUserPage(parentDir, path, key string) { func initUserPage(parentDir, path, key string) {
@ -103,6 +113,7 @@ func initUserPage(parentDir, path, key string) {
filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"), filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"),
filepath.Join(parentDir, templatesDir, "user", "include", "footer.tmpl"), filepath.Join(parentDir, templatesDir, "user", "include", "footer.tmpl"),
filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"), filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"),
filepath.Join(parentDir, templatesDir, "user", "include", "nav.tmpl"),
)) ))
} }
@ -200,3 +211,19 @@ func localHTML(term, lang string) template.HTML {
s = strings.Replace(s, "write.as", "<a href=\"https://writefreely.org\">writefreely</a>", 1) s = strings.Replace(s, "write.as", "<a href=\"https://writefreely.org\">writefreely</a>", 1)
return template.HTML(s) return template.HTML(s)
} }
// from: https://stackoverflow.com/a/18276968/1549194
func dict(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, errors.New("dict: invalid number of parameters")
}
dict := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, errors.New("dict: keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
}

@ -24,7 +24,7 @@
<nav id="user-nav"> <nav id="user-nav">
{{if .Username}} {{if .Username}}
<nav class="dropdown-nav"> <nav class="dropdown-nav">
<ul><li><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul> <ul><li class="has-submenu"><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
{{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}} {{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}}
<li><a href="/me/settings">Account settings</a></li> <li><a href="/me/settings">Account settings</a></li>
<li><a href="/me/export">Export</a></li> <li><a href="/me/export">Export</a></li>
@ -67,6 +67,7 @@
{{ template "footer" . }} {{ template "footer" . }}
{{if not .JSDisabled}} {{if not .JSDisabled}}
<script type="text/javascript" src="/js/menu.js"></script>
<script type="text/javascript"> <script type="text/javascript">
{{if .WebFonts}} {{if .WebFonts}}
try { // Google Fonts try { // Google Fonts

@ -40,7 +40,7 @@
</head> </head>
<body id="collection" itemscope itemtype="http://schema.org/WebPage"> <body id="collection" itemscope itemtype="http://schema.org/WebPage">
{{if or .IsOwner .SingleUser}}<nav id="manage"><ul> {{if or .IsOwner .SingleUser}}<nav id="manage"><ul>
<li><a onclick="void(0)">&#9776; Menu</a> <li class="has-submenu"><a onclick="void(0)">&#9776; Menu</a>
<ul> <ul>
{{ if .IsOwner }} {{ if .IsOwner }}
{{if .SingleUser}} {{if .SingleUser}}
@ -117,6 +117,7 @@
<script src="/js/h.js"></script> <script src="/js/h.js"></script>
<script src="/js/postactions.js"></script> <script src="/js/postactions.js"></script>
<script src="/js/localdate.js"></script> <script src="/js/localdate.js"></script>
<script type="text/javascript" src="/js/menu.js"></script>
<script type="text/javascript"> <script type="text/javascript">
var deleting = false; var deleting = false;
function delPost(e, id, owned) { function delPost(e, id, owned) {

@ -0,0 +1,37 @@
{{define "oauth-buttons"}}
{{ if or .SlackEnabled .WriteAsEnabled .GitLabEnabled .GiteaEnabled .GenericEnabled }}
<div class="row content-container signinbtns">
{{ if .SlackEnabled }}
<a class="loginbtn" href="/oauth/slack"><img alt="Sign in with Slack" height="40" width="172" src="/img/sign_in_with_slack.png" srcset="/img/sign_in_with_slack.png 1x, /img/sign_in_with_slack@2x.png 2x" /></a>
{{ end }}
{{ if .WriteAsEnabled }}
<a class="btn cta loginbtn" id="writeas-login" href="/oauth/write.as">
<img src="/img/mark/writeas-white.png" />
Sign in with <strong>Write.as</strong>
</a>
{{ end }}
{{ if .GitLabEnabled }}
<a class="btn cta loginbtn" id="gitlab-login" href="/oauth/gitlab">
<img src="/img/mark/gitlab.png" />
Sign in with <strong>{{.GitLabDisplayName}}</strong>
</a>
{{ end }}
{{ if .GiteaEnabled }}
<a class="btn cta loginbtn" id="gitea-login" href="/oauth/gitea">
<img src="/img/mark/gitea.png" />
Sign in with <strong>{{.GiteaDisplayName}}</strong>
</a>
{{ end }}
{{ if .GenericEnabled }}
<a class="btn cta loginbtn" id="generic-oauth-login" href="/oauth/generic">Sign in with <strong>{{.GenericDisplayName}}</strong></a>
{{ end }}
</div>
{{if not .DisablePasswordAuth}}
<div class="or">
<p>or</p>
<hr class="short" />
</div>
{{end}}
{{ end }}
{{end}}

@ -79,17 +79,20 @@
<!-- Include mathjax configuration --> <!-- Include mathjax configuration -->
{{define "mathjax"}} {{define "mathjax"}}
<script type="text/x-mathjax-config"> <script>
MathJax.Hub.Config({ MathJax = {
extensions: ["tex2jax.js"], tex: {
jax: ["input/TeX", "output/HTML-CSS"], inlineMath: [
tex2jax: { ["\\(", "\\)"],
inlineMath: [ ['$','$'], ["\\(","\\)"] ], ['$', '$'],
displayMath: [ ['$$','$$'], ["\\[","\\]"] ], ],
processEscapes: true displayMath: [
}, ['$$', '$$'],
"HTML-CSS": { fonts: ["TeX"] } ['\\[', '\\]'],
}); ],
},
};
</script>
<script type="text/javascript" id="MathJax-script" src="/js/mathjax/tex-svg-full.js" async>
</script> </script>
<script type="text/javascript" src="/js/mathjax/MathJax.js?config=TeX-MML-AM_CHTML" async></script>
{{end}} {{end}}

@ -24,7 +24,7 @@
{{if not .SingleUser}}<h1><a href="/me/c/" title="View blogs"><img class="ic-24dp" src="/img/ic_blogs_dark@2x.png" /></a></h1>{{end}} {{if not .SingleUser}}<h1><a href="/me/c/" title="View blogs"><img class="ic-24dp" src="/img/ic_blogs_dark@2x.png" /></a></h1>{{end}}
<nav id="target" {{if .SingleUser}}style="margin-left:0"{{end}}><ul> <nav id="target" {{if .SingleUser}}style="margin-left:0"{{end}}><ul>
{{if .Editing}}<li>{{if .EditCollection}}<a href="{{.EditCollection.CanonicalURL}}">{{.EditCollection.Title}}</a>{{else}}<a>Draft</a>{{end}}</li> {{if .Editing}}<li>{{if .EditCollection}}<a href="{{.EditCollection.CanonicalURL}}">{{.EditCollection.Title}}</a>{{else}}<a>Draft</a>{{end}}</li>
{{else}}<li><a id="publish-to"><span id="target-name">Draft</span> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /></a> {{else}}<li class="has-submenu"><a id="publish-to"><span id="target-name">Draft</span> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /></a>
<ul> <ul>
<li class="menu-heading">Publish to...</li> <li class="menu-heading">Publish to...</li>
{{if .Blogs}}{{range $idx, $el := .Blogs}} {{if .Blogs}}{{range $idx, $el := .Blogs}}
@ -45,7 +45,7 @@
</li>{{end}} </li>{{end}}
</ul></nav> </ul></nav>
<nav id="font-picker" class="if-room room-3 hidden" style="margin-left:-1em"><ul> <nav id="font-picker" class="if-room room-3 hidden" style="margin-left:-1em"><ul>
<li><a href="#" id="" onclick="return false"><img class="ic-24dp" src="/img/ic_font_dark@2x.png" /> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /></a> <li class="has-submenu"><a href="#" id="" onclick="return false"><img class="ic-24dp" src="/img/ic_font_dark@2x.png" /> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /></a>
<ul style="text-align: center"> <ul style="text-align: center">
<li class="menu-heading">Font</li> <li class="menu-heading">Font</li>
<li class="selected"><a class="font norm" href="#norm">Serif</a></li> <li class="selected"><a class="font norm" href="#norm">Serif</a></li>
@ -66,28 +66,50 @@
</header> </header>
<script src="/js/h.js"></script> <script src="/js/h.js"></script>
<script type="text/javascript" src="/js/menu.js"></script>
<script> <script>
function toggleTheme() { function toggleTheme() {
var btns = Array.prototype.slice.call(document.getElementById('tools').querySelectorAll('a img'));
var newTheme = '';
if (document.body.classList.contains('light')) { if (document.body.classList.contains('light')) {
newTheme = 'dark'; setTheme('dark');
document.body.className = document.body.className.replace(/(?:^|\s)light(?!\S)/g, newTheme); } else {
for (var i=0; i<btns.length; i++) { setTheme('light');
btns[i].src = btns[i].src.replace('_dark@2x.png', '@2x.png'); }
H.set('padTheme', newTheme);
}
function setTheme(newTheme) {
document.body.classList.remove('light');
document.body.classList.remove('dark');
document.body.classList.add(newTheme);
var btns = Array.prototype.slice.call(document.getElementById('tools').querySelectorAll('a img'));
if (newTheme == 'light') {
// check if current theme is dark otherwise we'll get `_dark_dark@2x.png`
if (H.get('padTheme', 'auto') == 'dark'){
for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('@2x.png', '_dark@2x.png');
}
} }
} else { } else {
newTheme = 'light';
document.body.className = document.body.className.replace(/(?:^|\s)dark(?!\S)/g, newTheme);
for (var i=0; i<btns.length; i++) { for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('@2x.png', '_dark@2x.png'); btns[i].src = btns[i].src.replace('_dark@2x.png', '@2x.png');
} }
} }
H.set('padTheme', newTheme); H.set('padTheme', newTheme);
} }
if (H.get('padTheme', 'light') != 'light') {
toggleTheme(); if (H.get('padTheme', 'auto') == 'light') {
setTheme('light');
} else if (H.get('padTheme', 'auto') == 'dark') {
setTheme('dark');
} else {
const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches
if (isDarkMode) {
setTheme('dark');
} else {
setTheme('light');
}
} }
var $writer = H.getEl('writer'); var $writer = H.getEl('writer');
var $btnPublish = H.getEl('publish'); var $btnPublish = H.getEl('publish');
var $btnEraseEdit = H.getEl('edited-elsewhere'); var $btnEraseEdit = H.getEl('edited-elsewhere');

@ -26,7 +26,7 @@
{{end}} {{end}}
</table> </table>
<nav class="pager"> <nav class="pager pages">
{{range $n := .TotalPages}}<a href="/admin/users{{if ne $n 1}}?p={{$n}}{{end}}" {{if eq $.CurPage $n}}class="selected"{{end}}>{{$n}}</a>{{end}} {{range $n := .TotalPages}}<a href="/admin/users{{if ne $n 1}}?p={{$n}}{{end}}" {{if eq $.CurPage $n}}class="selected"{{end}}>{{$n}}</a>{{end}}
</nav> </nav>

@ -20,7 +20,12 @@ textarea.section.norm {
{{if .Silenced}} {{if .Silenced}}
{{template "user-silenced"}} {{template "user-silenced"}}
{{end}} {{end}}
<h2>Customize {{.DisplayTitle}} <a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">view blog</a></h2>
{{template "collection-breadcrumbs" .}}
<h1>Customize</h1>
{{template "collection-nav" (dict "Alias" .Alias "Path" .Path "SingleUser" .SingleUser)}}
{{if .Flashes}}<ul class="errors"> {{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}} {{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}

@ -12,16 +12,18 @@
{{end}} {{end}}
<h1>Blogs</h1> <h1>Blogs</h1>
<ul class="atoms collections"> <ul class="atoms collections">
{{range $i, $el := .Collections}}<li class="collection"><h3> {{range $i, $el := .Collections}}<li class="collection">
<a class="title" href="/{{.Alias}}/">{{if .Title}}{{.Title}}{{else}}{{.Alias}}{{end}}</a> <div class="row lineitem">
</h3> <div>
<h4> <h3>
<a class="action new-post" href="{{if $.Chorus}}/new{{else}}/{{end}}#{{.Alias}}">new post</a> <a class="title" href="/{{.Alias}}/" >{{if .Title}}{{.Title}}{{else}}{{.Alias}}{{end}}</a>
<a class="action" href="/me/c/{{.Alias}}">customize</a> <span class="electron" {{if .IsPrivate}}style="font-style: italic"{{end}}>{{if .IsPrivate}}private{{else}}{{.DisplayCanonicalURL}}{{end}}</span>
<a class="action" href="/me/c/{{.Alias}}/stats">stats</a> </h3>
</h4> {{template "collection-nav" (dict "Alias" .Alias "Path" $.Path "SingleUser" $.SingleUser "CanPost" true )}}
{{if .Description}}<p class="description">{{.Description}}</p>{{end}} {{if .Description}}<p class="description">{{.Description}}</p>{{end}}
</li>{{end}} </div>
</div>
</li>{{end}}
<li id="create-collection"> <li id="create-collection">
{{if not .NewBlogsDisabled}} {{if not .NewBlogsDisabled}}
<form method="POST" action="/api/collections" id="new-collection-form" onsubmit="return createCollection()"> <form method="POST" action="/api/collections" id="new-collection-form" onsubmit="return createCollection()">

@ -20,6 +20,7 @@
</nav> </nav>
</footer> </footer>
<script type="text/javascript" src="/js/menu.js"></script>
<script type="text/javascript"> <script type="text/javascript">
try { // Google Fonts try { // Google Fonts
WebFontConfig = { WebFontConfig = {

@ -1,76 +1,78 @@
{{define "user-navigation"}} {{define "user-navigation"}}
<header class="{{if .SingleUser}}singleuser{{else}}multiuser{{end}}"> <header class="{{if .SingleUser}}singleuser{{else}}multiuser{{end}}">
<nav id="full-nav">
{{if .SingleUser}} {{if .SingleUser}}
<nav id="user-nav"> <nav id="user-nav">
<nav class="dropdown-nav"> <nav class="dropdown-nav">
<ul><li><a href="/" title="View blog" class="title">{{.SiteName}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /> <ul><li><a href="/" title="View blog" class="title">{{.SiteName}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" />
<ul> <ul>
<li><a href="/me/c/{{.Username}}">Customize</a></li> {{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}}
<li><a href="/me/c/{{.Username}}/stats">Stats</a></li> <li><a href="/me/settings">Account settings</a></li>
<li class="separator"><hr /></li> <li><a href="/me/import">Import posts</a></li>
{{if .IsAdmin}}<li><a href="/admin">Admin</a></li>{{end}} <li><a href="/me/export">Export</a></li>
<li><a href="/me/settings">Settings</a></li> <li class="separator"><hr /></li>
<li><a href="/me/import">Import posts</a></li> <li><a href="/me/logout">Log out</a></li>
<li><a href="/me/export">Export</a></li> </ul></li>
<li class="separator"><hr /></li> </ul>
<li><a href="/me/logout">Log out</a></li> </nav>
</ul></li> <nav class="tabs">
</ul> <a href="/me/c/{{.Username}}" {{if and (hasPrefix .Path "/me/c/") (hasSuffix .Path .Username)}}class="selected"{{end}}>Customize</a>
</nav> <a href="/me/c/{{.Username}}/stats" {{if hasSuffix .Path "/stats"}}class="selected"{{end}}>Stats</a>
<nav class="tabs"> <a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>
<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a> </nav>
<a href="/me/new">New Post</a>
</nav> </nav>
</nav> <div class="right-side">
<a class="simple-btn" href="/me/new">New Post</a>
</div>
{{else}} {{else}}
<nav id="full-nav">
<div class="left-side"> <div class="left-side">
<h1><a href="/" title="Return to editor">{{.SiteName}}</a></h1> <h1><a href="/" title="Return to editor">{{.SiteName}}</a></h1>
</div> </div>
<nav id="user-nav"> <nav id="user-nav">
{{if .Username}} {{if .Username}}
<nav class="dropdown-nav"> <nav class="dropdown-nav">
<ul><li><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul> <ul><li class="has-submenu"><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
{{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}} {{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}}
<li><a href="/me/settings">Account settings</a></li> <li><a href="/me/settings">Account settings</a></li>
<li><a href="/me/import">Import posts</a></li> <li><a href="/me/import">Import posts</a></li>
<li><a href="/me/export">Export</a></li> <li><a href="/me/export">Export</a></li>
{{if .CanInvite}}<li><a href="/me/invites">Invite people</a></li>{{end}} {{if .CanInvite}}<li><a href="/me/invites">Invite people</a></li>{{end}}
<li class="separator"><hr /></li> <li class="separator"><hr /></li>
<li><a href="/me/logout">Log out</a></li> <li><a href="/me/logout">Log out</a></li>
</ul></li> </ul></li>
</ul> </ul>
</nav> </nav>
{{end}}
<nav class="tabs">
{{if .SimpleNav}}
{{ if not .SingleUser }}
{{if and (and .LocalTimeline .CanViewReader) .Chorus}}<a href="/"{{if eq .Path "/"}} class="selected"{{end}}>Home</a>{{end}}
{{ end }}
<a href="/about">About</a>
{{ if not .SingleUser }}
{{ if .Username }}
{{if gt .MaxBlogs 1}}<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a>{{end}}
{{if and .Chorus (eq .MaxBlogs 1)}}<a href="/{{.Username}}/"{{if eq .Path (printf "/%s/" .Username)}} class="selected"{{end}}>My Posts</a>{{end}}
{{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}}
{{ end }}
{{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read">Reader</a>{{end}}
{{if and (and (and .Chorus .OpenRegistration) (not .Username)) (or (not .Private) (ne .Landing ""))}}<a href="/signup"{{if eq .Path "/signup"}} class="selected"{{end}}>Sign up</a>{{end}}
{{if .Username}}<a href="/me/logout">Log out</a>{{else}}<a href="/login">Log in</a>{{end}}
{{ end }}
{{else}}
<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a>
{{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}}
{{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read">Reader</a>{{end}}
{{end}} {{end}}
<nav class="tabs">
{{if .SimpleNav}}
{{ if not .SingleUser }}
{{if and (and .LocalTimeline .CanViewReader) .Chorus}}<a href="/"{{if eq .Path "/"}} class="selected"{{end}}>Home</a>{{end}}
{{ end }}
<a href="/about">About</a>
{{ if not .SingleUser }}
{{ if .Username }}
{{if gt .MaxBlogs 1}}<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a>{{end}}
{{if and .Chorus (eq .MaxBlogs 1)}}<a href="/{{.Username}}/"{{if eq .Path (printf "/%s/" .Username)}} class="selected"{{end}}>My Posts</a>{{end}}
{{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}}
{{ end }}
{{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read">Reader</a>{{end}}
{{if and (and (and .Chorus .OpenRegistration) (not .Username)) (or (not .Private) (ne .Landing ""))}}<a href="/signup"{{if eq .Path "/signup"}} class="selected"{{end}}>Sign up</a>{{end}}
{{if .Username}}<a href="/me/logout">Log out</a>{{else}}<a href="/login">Log in</a>{{end}}
{{ end }}
{{else}}
<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a>
{{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}}
{{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read">Reader</a>{{end}}
{{end}}
</nav>
</nav> </nav>
</nav> {{if .Username}}
{{if .Chorus}}{{if .Username}}<div class="right-side"> <div class="right-side">
<a class="simple-btn" href="/new">New Post</a> <a class="simple-btn" href="/{{if .CollAlias}}#{{.CollAlias}}{{end}}">New Post</a>
</div>{{end}} </div>
</nav> {{end}}
{{end}}
{{end}} {{end}}
</nav>
</header> </header>
{{end}} {{end}}
{{define "header"}}<!DOCTYPE HTML> {{define "header"}}<!DOCTYPE HTML>

@ -0,0 +1,16 @@
{{define "collection-breadcrumbs"}}
{{if and .Collection (not .SingleUser)}}<nav id="org-nav"><a href="/me/c/">Blogs</a> / <a class="coll-name" href="/{{.Collection.Alias}}/">{{.Collection.DisplayTitle}}</a></nav>{{end}}
{{end}}
{{define "collection-nav"}}
{{if not .SingleUser}}
<header class="admin">
<nav class="pager">
{{if .CanPost}}<a href="{{if .SingleUser}}/me/new{{else}}/#{{.Alias}}{{end}}" class="btn gentlecta">New Post</a>{{end}}
<a href="/me/c/{{.Alias}}" {{if and (hasPrefix .Path "/me/c/") (hasSuffix .Path .Alias)}}class="selected"{{end}}>Customize</a>
<a href="/me/c/{{.Alias}}/stats" {{if hasSuffix .Path "/stats"}}class="selected"{{end}}>Stats</a>
<a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">View Blog &rarr;</a>
</nav>
</header>
{{end}}
{{end}}

@ -16,7 +16,7 @@ h3 { font-weight: normal; }
{{if .Silenced}} {{if .Silenced}}
{{template "user-silenced"}} {{template "user-silenced"}}
{{end}} {{end}}
<h1>{{if .IsLogOut}}Before you go...{{else}}Account Settings {{if .IsAdmin}}<a href="/admin">admin settings</a>{{end}}{{end}}</h1> <h1>{{if .IsLogOut}}Before you go...{{else}}Account Settings{{end}}</h1>
{{if .Flashes}}<ul class="errors"> {{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}} {{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}} </ul>{{end}}
@ -41,6 +41,7 @@ h3 { font-weight: normal; }
</form> </form>
{{ end }} {{ end }}
{{if not .DisablePasswordAuth}}
<form method="post" action="/api/me/self" autocomplete="false"> <form method="post" action="/api/me/self" autocomplete="false">
<input type="hidden" name="logout" value="{{.IsLogOut}}" /> <input type="hidden" name="logout" value="{{.IsLogOut}}" />
<div class="option"> <div class="option">
@ -72,60 +73,84 @@ h3 { font-weight: normal; }
<input type="submit" value="Save changes" tabindex="4" /> <input type="submit" value="Save changes" tabindex="4" />
</div> </div>
</form> </form>
{{end}}
{{ if .OauthSection }} {{ if .OauthSection }}
<hr /> <hr />
{{ if .OauthAccounts }} {{ if .OauthAccounts }}
<div class="option"> <div class="option">
<h2>Linked Accounts</h2> <h2>Linked Accounts</h2>
<p>These are your linked external accounts.</p> <p>These are your linked external accounts.</p>
{{ range $oauth_account := .OauthAccounts }} {{ range $oauth_account := .OauthAccounts }}
<form method="post" action="/api/me/oauth/remove" autocomplete="false"> <form method="post" action="/api/me/oauth/remove" autocomplete="false">
<input type="hidden" name="provider" value="{{ $oauth_account.Provider }}" /> <input type="hidden" name="provider" value="{{ $oauth_account.Provider }}" />
<input type="hidden" name="client_id" value="{{ $oauth_account.ClientID }}" /> <input type="hidden" name="client_id" value="{{ $oauth_account.ClientID }}" />
<input type="hidden" name="remote_user_id" value="{{ $oauth_account.RemoteUserID }}" /> <input type="hidden" name="remote_user_id" value="{{ $oauth_account.RemoteUserID }}" />
<div class="section oauth-provider"> <div class="section oauth-provider">
<img src="/img/mark/{{$oauth_account.Provider}}.png" alt="{{ $oauth_account.Provider | title }}" /> {{ if $oauth_account.DisplayName}}
<input type="submit" value="Remove {{ $oauth_account.Provider | title }}" /> {{ if $oauth_account.AllowDisconnect}}
<input type="submit" value="Remove {{.DisplayName}}" />
{{else}}
<a class="btn cta"><strong>{{.DisplayName}}</strong></a>
{{end}}
{{else}}
<img src="/img/mark/{{$oauth_account.Provider}}.png" alt="{{ $oauth_account.Provider | title }}" />
<input type="submit" value="Remove {{ $oauth_account.Provider | title }}" />
{{end}}
</div> </div>
</form> </form>
{{ end }} {{ end }}
</div> </div>
{{ end }} {{ end }}
{{ if or .OauthSlack .OauthWriteAs .OauthGitLab }} {{ if or .OauthSlack .OauthWriteAs .OauthGitLab .OauthGeneric .OauthGitea }}
<div class="option"> <div class="option">
<h2>Link External Accounts</h2> <h2>Link External Accounts</h2>
<p>Connect additional accounts to enable logging in with those providers, instead of using your username and password.</p> <p>Connect additional accounts to enable logging in with those providers, instead of using your username and password.</p>
<div class="row"> <div class="row signinbtns">
{{ if .OauthWriteAs }} {{ if .OauthWriteAs }}
<div class="section oauth-provider"> <div class="section oauth-provider">
<img src="/img/mark/writeas.png" alt="Write.as" />
<a class="btn cta loginbtn" id="writeas-login" href="/oauth/write.as?attach=t"> <a class="btn cta loginbtn" id="writeas-login" href="/oauth/write.as?attach=t">
<img src="/img/mark/writeas-white.png" alt="Write.as" />
Link <strong>Write.as</strong> Link <strong>Write.as</strong>
</a> </a>
</div> </div>
{{ end }} {{ end }}
{{ if .OauthSlack }} {{ if .OauthSlack }}
<div class="section oauth-provider"> <div class="section oauth-provider">
<img src="/img/mark/slack.png" alt="Slack" /> <a class="btn cta loginbtn" id="slack-login" href="/oauth/slack?attach=t">
<a class="btn cta loginbtn" href="/oauth/slack?attach=t"> <img src="/img/mark/slack.png" alt="Slack" />
Link <strong>Slack</strong> Link <strong>Slack</strong>
</a> </a>
</div> </div>
{{ end }} {{ end }}
{{ if .OauthGitLab }} {{ if .OauthGitLab }}
<div class="section oauth-provider"> <div class="section oauth-provider">
<img src="/img/mark/gitlab.png" alt="GitLab" />
<a class="btn cta loginbtn" id="gitlab-login" href="/oauth/gitlab?attach=t"> <a class="btn cta loginbtn" id="gitlab-login" href="/oauth/gitlab?attach=t">
<img src="/img/mark/gitlab.png" alt="GitLab" />
Link <strong>{{.GitLabDisplayName}}</strong> Link <strong>{{.GitLabDisplayName}}</strong>
</a> </a>
</div> </div>
{{ end }} {{ end }}
{{ if .OauthGitea }}
<div class="section oauth-provider">
<a class="btn cta loginbtn" id="gitea-login" href="/oauth/gitea?attach=t">
<img src="/img/mark/gitea.png" alt="Gitea" />
Link <strong>{{.GiteaDisplayName}}</strong>
</a>
</div>
{{ end }}
{{ if .OauthGeneric }}
<div class="section oauth-provider">
<a class="btn cta loginbtn" id="generic-oauth-login" href="/oauth/generic?attach=t">
Link <strong>{{ .OauthGenericDisplayName }}</strong>
</a>
</div>
{{ end }}
</div> </div>
</div> </div>
{{ end }} {{ end }}
{{ end }} {{ end }}
</div> </div>
<script> <script>

@ -20,7 +20,14 @@ td.none {
{{if .Silenced}} {{if .Silenced}}
{{template "user-silenced"}} {{template "user-silenced"}}
{{end}} {{end}}
<h2 id="posts-header">{{if .Collection}}{{.Collection.DisplayTitle}} {{end}}Stats</h2>
{{template "collection-breadcrumbs" .}}
<h1 id="posts-header">Stats</h1>
{{if .Collection}}
{{template "collection-nav" (dict "Alias" .Collection.Alias "Path" .Path "SingleUser" .SingleUser)}}
{{end}}
<p>Stats for all time.</p> <p>Stats for all time.</p>

@ -0,0 +1 @@
!config.ini

@ -0,0 +1,2 @@
[server]
static_parent_dir = testdata

@ -0,0 +1,3 @@
body {
background-color: lightblue;
}
Loading…
Cancel
Save