diff --git a/Dockerfile b/Dockerfile index fd6589d..f4b5a0d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,19 @@ # Build image -FROM golang:1.13-alpine as build +FROM golang:1.14-alpine as build -RUN apk add --update nodejs nodejs-npm make g++ git sqlite-dev +RUN apk add --update nodejs nodejs-npm make g++ git RUN npm install -g less less-plugin-clean-css -RUN go get -u github.com/jteeuwen/go-bindata/... +RUN go get -u github.com/go-bindata/go-bindata/... RUN mkdir -p /go/src/github.com/writeas/writefreely WORKDIR /go/src/github.com/writeas/writefreely + COPY . . ENV GO111MODULE=on + RUN make build \ - && make ui + && make ui RUN mkdir /stage && \ cp -R /go/bin \ /go/src/github.com/writeas/writefreely/templates \ @@ -22,7 +24,7 @@ RUN mkdir /stage && \ /stage # Final image -FROM alpine:3.11 +FROM alpine:3.12 RUN apk add --no-cache openssl ca-certificates COPY --from=build --chown=daemon:daemon /stage /go diff --git a/account.go b/account.go index ba013c2..9b90942 100644 --- a/account.go +++ b/account.go @@ -151,8 +151,6 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr } // Handle empty optional params - // TODO: remove this var - createdWithPass := true hashedPass, err := auth.HashPass([]byte(signup.Pass)) if err != nil { return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} @@ -162,7 +160,7 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr u := &User{ Username: signup.Alias, HashedPass: hashedPass, - HasPass: createdWithPass, + HasPass: true, Email: prepareUserEmail(signup.Email, app.keys.EmailKey), Created: time.Now().Truncate(time.Second).UTC(), } @@ -188,9 +186,6 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr resUser := &AuthUser{ User: u, } - if !createdWithPass { - resUser.Password = signup.Pass - } title := signup.Alias if signup.Normalize { title = desiredUsername @@ -826,6 +821,9 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques return ErrCollectionNotFound } + // Add collection properties + c.MonetizationPointer = app.db.GetCollectionAttribute(c.ID, "monetization_pointer") + silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { log.Error("view edit collection %v", err) diff --git a/admin.go b/admin.go index 457b384..a0d10eb 100644 --- a/admin.go +++ b/admin.go @@ -529,6 +529,7 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt } apper.App().cfg.App.Federation = r.FormValue("federation") == "on" apper.App().cfg.App.PublicStats = r.FormValue("public_stats") == "on" + apper.App().cfg.App.Monetization = r.FormValue("monetization") == "on" apper.App().cfg.App.Private = r.FormValue("private") == "on" apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on" if apper.App().cfg.App.LocalTimeline && apper.App().timeline == nil { diff --git a/collections.go b/collections.go index edde677..e1ebe48 100644 --- a/collections.go +++ b/collections.go @@ -56,6 +56,8 @@ type ( PublicOwner bool `datastore:"public_owner" json:"-"` URL string `json:"url,omitempty"` + MonetizationPointer string `json:"monetization_pointer,omitempty"` + db *datastore hostName string } @@ -87,14 +89,15 @@ type ( Handle string `schema:"handle" json:"handle"` // Actual collection values updated in the DB - Alias *string `schema:"alias" json:"alias"` - Title *string `schema:"title" json:"title"` - Description *string `schema:"description" json:"description"` - StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"` - Script *sql.NullString `schema:"script" json:"script"` - Signature *sql.NullString `schema:"signature" json:"signature"` - Visibility *int `schema:"visibility" json:"public"` - Format *sql.NullString `schema:"format" json:"format"` + Alias *string `schema:"alias" json:"alias"` + Title *string `schema:"title" json:"title"` + Description *string `schema:"description" json:"description"` + StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"` + Script *sql.NullString `schema:"script" json:"script"` + Signature *sql.NullString `schema:"signature" json:"signature"` + Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"` + Visibility *int `schema:"visibility" json:"public"` + Format *sql.NullString `schema:"format" json:"format"` } CollectionFormat struct { Format string @@ -552,6 +555,7 @@ type CollectionPage struct { IsOwner bool CanPin bool Username string + Monetization string Collections *[]Collection PinnedPosts *[]PublicPost IsAdmin bool @@ -723,14 +727,14 @@ func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCo return coll } +// getCollectionPage returns the collection page as an int. If the parsed page value is not +// greater than 0 then the default value of 1 is returned. func getCollectionPage(vars map[string]string) int { - page := 1 - var p int - p, _ = strconv.Atoi(vars["page"]) - if p > 0 { - page = p + if p, _ := strconv.Atoi(vars["page"]); p > 0 { + return p } - return page + + return 1 } // handleViewCollection displays the requested Collection @@ -829,6 +833,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro // Add more data // TODO: fix this mess of collections inside collections displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) + displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") collTmpl := "collection" if app.cfg.App.Chorus { @@ -947,6 +952,7 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e // Add more data // TODO: fix this mess of collections inside collections displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) + displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage) if err != nil { diff --git a/config.ini.example b/config.ini.example deleted file mode 100644 index 8b74ddc..0000000 --- a/config.ini.example +++ /dev/null @@ -1,28 +0,0 @@ -[server] -hidden_host = -port = 8080 - -[database] -type = mysql -username = root -password = changeme -database = writefreely -host = db -port = 3306 -tls = false - -[app] -site_name = WriteFreely Example Blog! -host = http://localhost:8080 -theme = write -disable_js = false -webfonts = true -single_user = true -open_registration = false -min_username_len = 3 -max_blogs = 1 -federation = true -public_stats = true -private = false -update_checks = true - diff --git a/config/config.go b/config/config.go index 0fce241..3a5588b 100644 --- a/config/config.go +++ b/config/config.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2019 A Bunch Tell LLC. + * Copyright © 2018-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -108,6 +108,7 @@ type ( TokenEndpoint string `ini:"token_endpoint"` InspectEndpoint string `ini:"inspect_endpoint"` AuthEndpoint string `ini:"auth_endpoint"` + Scope string `ini:"scope"` AllowDisconnect bool `ini:"allow_disconnect"` } @@ -137,10 +138,12 @@ type ( MinUsernameLen int `ini:"min_username_len"` MaxBlogs int `ini:"max_blogs"` + // Options for public instances // Federation - Federation bool `ini:"federation"` - PublicStats bool `ini:"public_stats"` - NotesOnly bool `ini:"notes_only"` + Federation bool `ini:"federation"` + PublicStats bool `ini:"public_stats"` + Monetization bool `ini:"monetization"` + NotesOnly bool `ini:"notes_only"` // Access Private bool `ini:"private"` diff --git a/database.go b/database.go index 8237e41..54939fe 100644 --- a/database.go +++ b/database.go @@ -905,6 +905,29 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro } } + // Update Monetization value + if c.Monetization != nil { + skipUpdate := false + if *c.Monetization != "" { + // Strip away any excess spaces + trimmed := strings.TrimSpace(*c.Monetization) + // Only update value when it starts with "$", per spec: https://paymentpointers.org + if strings.HasPrefix(trimmed, "$") { + c.Monetization = &trimmed + } else { + // Value appears invalid, so don't update + skipUpdate = true + } + } + if !skipUpdate { + _, err = db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE value = ?", collID, "monetization_pointer", *c.Monetization, *c.Monetization) + if err != nil { + log.Error("Unable to insert monetization_pointer value: %v", err) + return err + } + } + } + // Update rest of the collection data res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...) if err != nil { @@ -2162,6 +2185,28 @@ func (db *datastore) CollectionHasAttribute(id int64, attr string) bool { return true } +func (db *datastore) GetCollectionAttribute(id int64, attr string) string { + var v string + err := db.QueryRow("SELECT value FROM collectionattributes WHERE collection_id = ? AND attribute = ?", id, attr).Scan(&v) + switch { + case err == sql.ErrNoRows: + return "" + case err != nil: + log.Error("Couldn't SELECT value in getCollectionAttribute for attribute '%s': %v", attr, err) + return "" + } + return v +} + +func (db *datastore) SetCollectionAttribute(id int64, attr, v string) error { + _, err := db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?)", id, attr, v) + if err != nil { + log.Error("Unable to INSERT into collectionattributes: %v", err) + return err + } + return nil +} + // DeleteAccount will delete the entire account for userID func (db *datastore) DeleteAccount(userID int64) error { // Get all collections diff --git a/docker-compose.yml b/docker-compose.yml index 29a841e..ef73a9b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,32 +1,47 @@ version: "3" -services: - web: - build: . - volumes: - - "web-data:/go/src/app" - - "./config.ini.example:/go/src/app/config.ini" - ports: - - "8080:8080" - networks: - - writefreely - depends_on: - - db - restart: unless-stopped - db: - image: "mariadb:latest" - volumes: - - "./schema.sql:/tmp/schema.sql" - - db-data:/var/lib/mysql/data - networks: - - writefreely - environment: - - MYSQL_DATABASE=writefreely - - MYSQL_ROOT_PASSWORD=changeme - restart: unless-stopped volumes: - web-data: + web-keys: db-data: networks: - writefreely: + external_writefreely: + internal_writefreely: + internal: true + +services: + writefreely-web: + container_name: "writefreely-web" + image: "writefreely:latest" + + volumes: + - "web-keys:/go/keys" + - "./config.ini:/go/config.ini" + + networks: + - "internal_writefreely" + - "external_writefreely" + + ports: + - "8080:8080" + + depends_on: + - "writefreely-db" + + restart: unless-stopped + + writefreely-db: + container_name: "writefreely-db" + image: "mariadb:latest" + + volumes: + - "db-data:/var/lib/mysql/data" + + networks: + - "internal_writefreely" + + environment: + - MYSQL_DATABASE=writefreely + - MYSQL_ROOT_PASSWORD=changeme + + restart: unless-stopped diff --git a/go.mod b/go.mod index 82d48a5..d7ffbec 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect 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/mux v1.8.0 github.com/gorilla/schema v1.2.0 github.com/gorilla/sessions v1.2.0 github.com/guregu/null v3.5.0+incompatible @@ -21,10 +21,10 @@ require ( github.com/jtolds/gls v4.2.1+incompatible // indirect github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec github.com/lunixbochs/vtclean v1.0.0 // indirect - github.com/manifoldco/promptui v0.7.0 - github.com/mattn/go-sqlite3 v1.14.2 - github.com/microcosm-cc/bluemonday v1.0.3 - github.com/mitchellh/go-wordwrap v1.0.0 + github.com/manifoldco/promptui v0.8.0 + github.com/mattn/go-sqlite3 v1.14.4 + github.com/microcosm-cc/bluemonday v1.0.4 + github.com/mitchellh/go-wordwrap v1.0.1 github.com/nicksnyder/go-i18n v1.10.0 // indirect github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d github.com/pelletier/go-toml v1.2.0 // indirect @@ -54,7 +54,7 @@ require ( golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 // indirect google.golang.org/appengine v1.4.0 // indirect gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect - gopkg.in/ini.v1 v1.57.0 + gopkg.in/ini.v1 v1.61.0 src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b // indirect ) diff --git a/go.sum b/go.sum index 6e132c2..33d0ff0 100644 --- a/go.sum +++ b/go.sum @@ -73,6 +73,8 @@ github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY= github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA= github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/schema v1.0.2 h1:sAgNfOcNYvdDSrzGHVy9nzCQahG+qmsg+nE8dK85QRA= github.com/gorilla/schema v1.0.2/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY= @@ -116,6 +118,8 @@ github.com/manifoldco/promptui v0.3.2 h1:rir7oByTERac6jhpHUPErHuopoRDvO3jxS+Fdad github.com/manifoldco/promptui v0.3.2/go.mod h1:8JU+igZ+eeiiRku4T5BjtKh2ms8sziGpSYl1gN8Bazw= github.com/manifoldco/promptui v0.7.0 h1:3l11YT8tm9MnwGFQ4kETwkzpAwY2Jt9lCrumCUW4+z4= github.com/manifoldco/promptui v0.7.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= +github.com/manifoldco/promptui v0.8.0 h1:R95mMF+McvXZQ7j1g8ucVZE1gLP3Sv6j9vlF9kyRqQo= +github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.0 h1:v2XXALHHh6zHfYTJ+cSkwtyffnaOyR1MXaA91mTrb8o= @@ -133,12 +137,18 @@ github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/ 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/mattn/go-sqlite3 v1.14.4 h1:4rQjbDxdu9fSgI/r3KN72G3c2goxknAqHHgPWWs8UlI= +github.com/mattn/go-sqlite3 v1.14.4/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= 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/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/nicksnyder/go-i18n v1.10.0 h1:5AzlPKvXBH4qBzmZ09Ua9Gipyruv6uApMcrNZdo96+Q= github.com/nicksnyder/go-i18n v1.10.0/go.mod h1:HrK7VCrbOvQoUAQ7Vpy7i87N7JZZZ7R2xBGjv0j365Q= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= @@ -264,6 +274,8 @@ gopkg.in/ini.v1 v1.55.0 h1:E8yzL5unfpW3M6fz/eB7Cb5MQAYSZ7GKo4Qth+N2sgQ= gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.57.0 h1:9unxIsFcTt4I55uWluz+UmL95q4kdJ0buvQ1ZIqVQww= gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.61.0 h1:LBCdW4FmFYL4s/vDZD1RQYX7oAR6IjujCYgMdbHBR10= +gopkg.in/ini.v1 v1.61.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= diff --git a/handle.go b/handle.go index fe03757..5e15137 100644 --- a/handle.go +++ b/handle.go @@ -926,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/oauth.go b/oauth.go index e3f65ef..6cbddff 100644 --- a/oauth.go +++ b/oauth.go @@ -265,6 +265,7 @@ func configureGenericOauth(parentHandler *Handler, r *mux.Router, app *App) { AuthLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.AuthEndpoint, HttpClient: config.DefaultHTTPClient(), CallbackLocation: callbackLocation, + Scope: config.OrDefaultString(app.Config().GenericOauth.Scope, "read_user"), } configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy) } diff --git a/oauth_generic.go b/oauth_generic.go index ce65bca..cb82ad0 100644 --- a/oauth_generic.go +++ b/oauth_generic.go @@ -15,6 +15,7 @@ type genericOauthClient struct { ExchangeLocation string InspectLocation string CallbackLocation string + Scope string HttpClient HttpClient } @@ -46,7 +47,7 @@ func (c genericOauthClient) buildLoginURL(state string) (string, error) { q.Set("redirect_uri", c.CallbackLocation) q.Set("response_type", "code") q.Set("state", state) - q.Set("scope", "read_user") + q.Set("scope", c.Scope) u.RawQuery = q.Encode() return u.String(), nil } @@ -55,7 +56,7 @@ func (c genericOauthClient) exchangeOauthCode(ctx context.Context, code string) form := url.Values{} form.Add("grant_type", "authorization_code") form.Add("redirect_uri", c.CallbackLocation) - form.Add("scope", "read_user") + form.Add("scope", c.Scope) form.Add("code", code) req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode())) if err != nil { @@ -110,5 +111,6 @@ func (c genericOauthClient) inspectOauthAccessToken(ctx context.Context, accessT if inspectResponse.Error != "" { return nil, errors.New(inspectResponse.Error) } + return &inspectResponse, nil } 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/parse/posts.go b/parse/posts.go index 6cd5217..f660e08 100644 --- a/parse/posts.go +++ b/parse/posts.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018 A Bunch Tell LLC. + * Copyright © 2018-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -57,6 +57,11 @@ func PostLede(t string, includePunc bool) string { c := []rune(t) t = string(c[:punc+iAdj]) } + punc = stringmanip.IndexRune(t, '?') + if punc > -1 { + c := []rune(t) + t = string(c[:punc+iAdj]) + } return t } diff --git a/postrender.go b/postrender.go index f917b6e..ccfc565 100644 --- a/postrender.go +++ b/postrender.go @@ -16,6 +16,7 @@ import ( "html" "html/template" "net/http" + "net/url" "regexp" "strings" "unicode" @@ -73,6 +74,25 @@ func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string { return applyMarkdownSpecial(data, false, baseURL, cfg) } +func disableYoutubeAutoplay(outHTML string) string { + for _, match := range youtubeReg.FindAllString(outHTML, -1) { + u, err := url.Parse(match) + if err != nil { + continue + } + u.RawQuery = html.UnescapeString(u.RawQuery) + q := u.Query() + // Set Youtube autoplay url parameter, if any, to 0 + if len(q["autoplay"]) == 1 { + q.Set("autoplay", "0") + } + u.RawQuery = q.Encode() + cleanURL := u.String() + outHTML = strings.Replace(outHTML, match, cleanURL, 1) + } + return outHTML +} + func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *config.Config) string { mdExtensions := 0 | blackfriday.EXTENSION_TABLES | @@ -108,10 +128,7 @@ func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *c // Strip newlines on certain block elements that render with them outHTML = blockReg.ReplaceAllString(outHTML, "<$1>") outHTML = endBlockReg.ReplaceAllString(outHTML, "") - // Remove all query parameters on YouTube embed links - // TODO: make this more specific. Taking the nuclear approach here to strip ?autoplay=1 - outHTML = youtubeReg.ReplaceAllString(outHTML, "$1") - + outHTML = disableYoutubeAutoplay(outHTML) return outHTML } @@ -140,9 +157,7 @@ func applyBasicMarkdown(data []byte) string { func postTitle(content, friendlyId string) string { const maxTitleLen = 80 - // Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML - // entities added in by sanitizing the content. - content = html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content)) + content = stripHTMLWithoutEscaping(content) content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace) eol := strings.IndexRune(content, '\n') @@ -160,9 +175,7 @@ func postTitle(content, friendlyId string) string { func friendlyPostTitle(content, friendlyId string) string { const maxTitleLen = 80 - // Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML - // entities added in by sanitizing the content. - content = html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content)) + content = stripHTMLWithoutEscaping(content) content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace) eol := strings.IndexRune(content, '\n') @@ -179,6 +192,12 @@ func friendlyPostTitle(content, friendlyId string) string { return title } +// Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML +// entities added in by sanitizing the content. +func stripHTMLWithoutEscaping(content string) string { + return html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content)) +} + func getSanitizationPolicy() *bluemonday.Policy { policy := bluemonday.UGCPolicy() policy.AllowAttrs("src", "style").OnElements("iframe", "video", "audio") diff --git a/posts.go b/posts.go index 44b215c..0fe29b9 100644 --- a/posts.go +++ b/posts.go @@ -211,8 +211,7 @@ func (p Post) Summary() string { if p.Content == "" { return "" } - // Strip out HTML - p.Content = bluemonday.StrictPolicy().Sanitize(p.Content) + p.Content = stripHTMLWithoutEscaping(p.Content) // and Markdown p.Content = stripmd.Strip(p.Content) @@ -1481,6 +1480,7 @@ Are you sure it was ever here?`, IsOwner bool IsPinned bool IsCustomDomain bool + Monetization string PinnedPosts *[]PublicPost IsFound bool IsAdmin bool @@ -1498,6 +1498,7 @@ Are you sure it was ever here?`, tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin) tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner) tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p) + tp.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") if !postFound { w.WriteHeader(http.StatusNotFound) diff --git a/posts_test.go b/posts_test.go new file mode 100644 index 0000000..e423fd3 --- /dev/null +++ b/posts_test.go @@ -0,0 +1,35 @@ +package writefreely_test + +import ( + "testing" + + "github.com/guregu/null/zero" + "github.com/stretchr/testify/assert" + "github.com/writeas/writefreely" +) + +func TestPostSummary(t *testing.T) { + testCases := map[string]struct { + given writefreely.Post + expected string + }{ + "no special chars": {givenPost("Content."), "Content."}, + "HTML content": {givenPost("Content

with a

paragraph."), "Content with a paragraph."}, + "content with escaped char": {givenPost("Content's all OK."), "Content's all OK."}, + "multiline content": {givenPost(`Content +in +multiple +lines.`), "Content in multiple lines."}, + } + + for name, test := range testCases { + t.Run(name, func(t *testing.T) { + actual := test.given.Summary() + assert.Equal(t, test.expected, actual) + }) + } +} + +func givenPost(content string) writefreely.Post { + return writefreely.Post{Title: zero.StringFrom("Title"), Content: content} +} diff --git a/routes.go b/routes.go index 47c4f19..bb1785f 100644 --- a/routes.go +++ b/routes.go @@ -26,6 +26,7 @@ import ( func (app *App) InitStaticRoutes(r *mux.Router) { // Handle static files fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir))) + fs = cacheControl(fs) app.shttp = http.NewServeMux() app.shttp.Handle("/", fs) r.PathPrefix("/").Handler(fs) @@ -206,7 +207,6 @@ func RouteCollections(handler *Handler, r *mux.Router) { r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader)) r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader)) r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader)) - r.HandleFunc("/tags/{tag}", handler.Web(handleViewCollectionTag, UserLevelReader)) r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap)) r.HandleFunc("/feed/", handler.AllReader(ViewFeed)) r.HandleFunc("/{slug}", handler.CollectionPostOrStatic) diff --git a/routes_test.go b/routes_test.go new file mode 100644 index 0000000..9669e6e --- /dev/null +++ b/routes_test.go @@ -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) + } + } +} diff --git a/templates/chorus-collection-post.tmpl b/templates/chorus-collection-post.tmpl index dcea457..22f2d8f 100644 --- a/templates/chorus-collection-post.tmpl +++ b/templates/chorus-collection-post.tmpl @@ -29,6 +29,7 @@ {{range .Images}}{{else}}{{end}} + {{template "collection-meta" .}} {{if .Collection.StyleSheet}}{{end}} {{end}} {{end}} {{if .Collection.RenderMathJax}} @@ -62,7 +63,7 @@ {{if .Silenced}} {{template "user-silenced"}} {{end}} -
{{if .IsScheduled}}

Scheduled

{{end}}{{if .Title.String}}

{{.FormattedDisplayTitle}}

{{end}}{{if and $.Collection.Format.ShowDates (not .IsPinned) .IsFound}}{{end}}
{{.HTMLContent}}
+
{{if .IsScheduled}}

Scheduled

{{end}}{{if .Title.String}}

{{.FormattedDisplayTitle}}

{{end}}{{if and $.Collection.Format.ShowDates (not .IsPinned) .IsFound}}{{end}}
{{.HTMLContent}}
{{ if .Collection.ShowFooterBranding }} diff --git a/templates/collection-tags.tmpl b/templates/collection-tags.tmpl index b7c92c8..e2f8962 100644 --- a/templates/collection-tags.tmpl +++ b/templates/collection-tags.tmpl @@ -29,6 +29,7 @@ + {{template "collection-meta" .}} {{if .Collection.StyleSheet}}{{end}} {{if .Collection.RenderMathJax}} diff --git a/templates/collection.tmpl b/templates/collection.tmpl index d39c58c..42664e7 100644 --- a/templates/collection.tmpl +++ b/templates/collection.tmpl @@ -27,6 +27,7 @@ + {{template "collection-meta" .}} {{if .StyleSheet}}{{end}} {{if .RenderMathJax}} diff --git a/templates/include/post-render.tmpl b/templates/include/post-render.tmpl index 81fd33e..c4ed082 100644 --- a/templates/include/post-render.tmpl +++ b/templates/include/post-render.tmpl @@ -1,4 +1,10 @@ +{{define "collection-meta"}} + {{if .Monetization -}} + + {{- end}} +{{end}} + {{define "highlighting"}} {{template "footer" .}} diff --git a/testdata/.gitignore b/testdata/.gitignore new file mode 100644 index 0000000..0573cf2 --- /dev/null +++ b/testdata/.gitignore @@ -0,0 +1 @@ +!config.ini diff --git a/testdata/config.ini b/testdata/config.ini new file mode 100644 index 0000000..ed3a86d --- /dev/null +++ b/testdata/config.ini @@ -0,0 +1,2 @@ +[server] +static_parent_dir = testdata diff --git a/testdata/static/style.css b/testdata/static/style.css new file mode 100644 index 0000000..eed8207 --- /dev/null +++ b/testdata/static/style.css @@ -0,0 +1,3 @@ +body { + background-color: lightblue; +}