diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7ab0722 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[*.go] +indent_style = tab diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index d3bc432..683d5c4 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,4 +1,4 @@ -name: Build container image, publish as Github-package +name: Build container image, publish as GitHub-package # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by @@ -28,13 +28,13 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Login against a Docker registry except on PR # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v3.0.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -44,7 +44,7 @@ jobs: # https://github.com/docker/metadata-action - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v4.1.1 + uses: docker/metadata-action@v4.6.0 with: images: | ghcr.io/${{ github.repository }} @@ -53,7 +53,7 @@ jobs: # Build and push Docker image with Buildx (don't push on PR) # https://github.com/docker/build-push-action - name: Build and push Docker images - uses: docker/build-push-action@v3.2.0 + uses: docker/build-push-action@v5.0.0 with: context: . push: ${{ github.event_name != 'pull_request' }} diff --git a/Dockerfile b/Dockerfile index c6d3f8d..77061c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ # Build image -FROM golang:1.15-alpine as build +FROM golang:1.19-alpine as build + +LABEL org.opencontainers.image.source=https://github.com/writefreely/writefreely +LABEL org.opencontainers.image.description="WriteFreely is a clean, minimalist publishing platform made for writers. Start a blog, share knowledge within your organization, or build a community around the shared act of writing." RUN apk add --update nodejs npm make g++ git RUN npm install -g less less-plugin-clean-css @@ -10,7 +13,10 @@ WORKDIR /go/src/github.com/writefreely/writefreely COPY . . +RUN cat ossl_legacy.cnf > /etc/ssl/openssl.cnf + ENV GO111MODULE=on +ENV NODE_OPTIONS=--openssl-legacy-provider RUN make build \ && make ui diff --git a/Makefile b/Makefile index 10d04b2..3eabbf7 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ GITREV=`git describe | cut -c 2-` -LDFLAGS=-ldflags="-X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)'" +LDFLAGS=-ldflags="-s -w -X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)'" GOCMD=go GOINSTALL=$(GOCMD) install $(LDFLAGS) @@ -17,47 +17,47 @@ all : build ci: ci-assets deps cd cmd/writefreely; $(GOBUILD) -v -build: assets deps - cd cmd/writefreely; $(GOBUILD) -v -tags='sqlite' +build: deps + cd cmd/writefreely; $(GOBUILD) -v -tags='netgo sqlite' -build-no-sqlite: assets-no-sqlite deps-no-sqlite - cd cmd/writefreely; $(GOBUILD) -v -o $(BINARY_NAME) +build-no-sqlite: deps-no-sqlite + cd cmd/writefreely; $(GOBUILD) -v -tags='netgo' -o $(BINARY_NAME) build-linux: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ - $(GOGET) -u src.techknowlogick.com/xgo; \ + $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi - xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -go go-1.15.x -out writefreely ./cmd/writefreely + xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely . build-windows: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ - $(GOGET) -u src.techknowlogick.com/xgo; \ + $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi - xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -go go-1.15.x -out writefreely ./cmd/writefreely + xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely . build-darwin: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ - $(GOGET) -u src.techknowlogick.com/xgo; \ + $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi - xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -go go-1.15.x -out writefreely ./cmd/writefreely + xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely . build-arm6: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ - $(GOGET) -u src.techknowlogick.com/xgo; \ + $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi - xgo --targets=linux/arm-6, -dest build/ $(LDFLAGS) -tags='sqlite' -go go-1.15.x -out writefreely ./cmd/writefreely + xgo --targets=linux/arm-6, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely . build-arm7: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ - $(GOGET) -u src.techknowlogick.com/xgo; \ + $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi - xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='sqlite' -go go-1.15.x -out writefreely ./cmd/writefreely + xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely . build-arm64: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ - $(GOGET) -u src.techknowlogick.com/xgo; \ + $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi - xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='sqlite' -go go-1.15.x -out writefreely ./cmd/writefreely + xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely . build-docker : $(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) . @@ -65,8 +65,8 @@ build-docker : test: $(GOTEST) -v ./... -run: dev-assets - $(GOINSTALL) -tags='sqlite' ./... +run: + $(GOINSTALL) -tags='netgo sqlite' ./... $(BINARY_NAME) --debug deps : @@ -86,6 +86,7 @@ release : clean ui assets cp -r templates $(BUILDPATH) cp -r pages $(BUILDPATH) cp -r static $(BUILDPATH) + rm -r $(BUILDPATH)/static/local scripts/invalidate-css.sh $(BUILDPATH) mkdir $(BUILDPATH)/keys $(MAKE) build-linux diff --git a/account.go b/account.go index 91a8ace..423dee2 100644 --- a/account.go +++ b/account.go @@ -13,6 +13,8 @@ package writefreely import ( "encoding/json" "fmt" + "github.com/mailgun/mailgun-go" + "github.com/writefreely/writefreely/spam" "html/template" "net/http" "regexp" @@ -323,6 +325,7 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error { To string Message template.HTML Flashes []template.HTML + EmailEnabled bool LoginUsername string }{ StaticPage: pageForReq(app, r), @@ -330,6 +333,7 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error { To: r.FormValue("to"), Message: template.HTML(""), Flashes: []template.HTML{}, + EmailEnabled: app.cfg.Email.Enabled(), LoginUsername: getTempInfo(app, "login-user", r, w), } @@ -504,7 +508,7 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error { // User has no email set, so check if they haven't added a password, either, // so we can return a more helpful error message. if hasPass, _ := app.db.IsUserPassSet(u.ID); !hasPass { - log.Info("Tried logging in to %s, but no password or email.", signin.Alias) + log.Info("Tried logging into %s, but no password or email.", signin.Alias) return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."} } } @@ -577,7 +581,7 @@ func getVerboseAuthUser(app *App, token string, u *User, verbose bool) *AuthUser } passIsSet, err := app.db.IsUserPassSet(u.ID) if err != nil { - // TODO: correct error meesage + // TODO: correct error message log.Error("Login: Unable to get user collections: %v", err) } @@ -862,9 +866,6 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques return ErrCollectionNotFound } - // Add collection properties - c.Monetization = app.db.GetCollectionAttribute(c.ID, "monetization_pointer") - silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { if err == ErrUserNotFound { @@ -878,12 +879,19 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques *UserPage *Collection Silenced bool + + config.EmailCfg + LetterReplyTo string }{ UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes), Collection: c, Silenced: silenced, + EmailCfg: app.cfg.Email, } obj.UserPage.CollAlias = c.Alias + if obj.EmailCfg.Enabled() { + obj.LetterReplyTo = app.db.GetCollectionAttribute(c.ID, collAttrLetterReplyTo) + } showUserPage(w, "collection", obj) return nil @@ -1055,17 +1063,20 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error } obj := struct { *UserPage - VisitsBlog string - Collection *Collection - TopPosts *[]PublicPost - APFollowers int - Silenced bool + VisitsBlog string + Collection *Collection + TopPosts *[]PublicPost + APFollowers int + EmailEnabled bool + EmailSubscribers int + Silenced bool }{ - UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes), - VisitsBlog: alias, - Collection: c, - TopPosts: topPosts, - Silenced: silenced, + UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes), + VisitsBlog: alias, + Collection: c, + TopPosts: topPosts, + EmailEnabled: app.cfg.Email.Enabled(), + Silenced: silenced, } obj.UserPage.CollAlias = c.Alias if app.cfg.App.Federation { @@ -1075,11 +1086,73 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error } obj.APFollowers = len(*folls) } + if obj.EmailEnabled { + subs, err := app.db.GetEmailSubscribers(c.ID, true) + if err != nil { + return err + } + obj.EmailSubscribers = len(subs) + } showUserPage(w, "stats", obj) return nil } +func handleViewSubscribers(app *App, u *User, w http.ResponseWriter, r *http.Request) error { + vars := mux.Vars(r) + c, err := app.db.GetCollection(vars["collection"]) + if err != nil { + return err + } + + filter := r.FormValue("filter") + + flashes, _ := getSessionFlashes(app, w, r, nil) + obj := struct { + *UserPage + Collection CollectionNav + EmailSubs []*EmailSubscriber + Followers *[]RemoteUser + Silenced bool + + Filter string + FederationEnabled bool + CanEmailSub bool + CanAddSubs bool + EmailSubsEnabled bool + }{ + UserPage: NewUserPage(app, r, u, c.DisplayTitle()+" Subscribers", flashes), + Collection: CollectionNav{ + Collection: c, + Path: r.URL.Path, + SingleUser: app.cfg.App.SingleUser, + }, + Silenced: u.IsSilenced(), + Filter: filter, + FederationEnabled: app.cfg.App.Federation, + CanEmailSub: app.cfg.Email.Enabled(), + EmailSubsEnabled: c.EmailSubsEnabled(), + } + + obj.Followers, err = app.db.GetAPFollowers(c) + if err != nil { + return err + } + + obj.EmailSubs, err = app.db.GetEmailSubscribers(c.ID, true) + if err != nil { + return err + } + + if obj.Filter == "" { + // Set permission to add email subscribers + //obj.CanAddSubs = app.db.GetUserAttribute(c.OwnerID, userAttrCanAddEmailSubs) == "1" + } + + showUserPage(w, "subscribers", obj) + return nil +} + func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error { fullUser, err := app.db.GetUserByID(u.ID) if err != nil { @@ -1168,6 +1241,211 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err return nil } +func viewResetPassword(app *App, w http.ResponseWriter, r *http.Request) error { + token := r.FormValue("t") + resetting := false + var userID int64 = 0 + if token != "" { + // Show new password page + userID = app.db.GetUserFromPasswordReset(token) + if userID == 0 { + return impart.HTTPError{http.StatusNotFound, ""} + } + resetting = true + } + + if r.Method == http.MethodPost { + newPass := r.FormValue("new-pass") + if newPass == "" { + // Send password reset email + return handleResetPasswordInit(app, w, r) + } + + // Do actual password reset + // Assumes token has been validated above + err := doAutomatedPasswordChange(app, userID, newPass) + if err != nil { + return err + } + err = app.db.ConsumePasswordResetToken(token) + if err != nil { + log.Error("Couldn't consume token %s for user %d!!! %s", token, userID, err) + } + addSessionFlash(app, w, r, "Your password was reset. Now you can log in below.", nil) + return impart.HTTPError{http.StatusFound, "/login"} + } + + f, _ := getSessionFlashes(app, w, r, nil) + + // Show reset password page + d := struct { + page.StaticPage + Flashes []string + EmailEnabled bool + CSRFField template.HTML + Token string + IsResetting bool + IsSent bool + }{ + StaticPage: pageForReq(app, r), + Flashes: f, + EmailEnabled: app.cfg.Email.Enabled(), + CSRFField: csrf.TemplateField(r), + Token: token, + IsResetting: resetting, + IsSent: r.FormValue("sent") == "1", + } + err := pages["reset.tmpl"].ExecuteTemplate(w, "base", d) + if err != nil { + log.Error("Unable to render password reset page: %v", err) + return err + } + return err +} + +func doAutomatedPasswordChange(app *App, userID int64, newPass string) error { + // Do password reset + hashedPass, err := auth.HashPass([]byte(newPass)) + if err != nil { + return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} + } + + // Do update + err = app.db.ChangePassphrase(userID, true, "", hashedPass) + if err != nil { + return err + } + return nil +} + +func handleResetPasswordInit(app *App, w http.ResponseWriter, r *http.Request) error { + returnLoc := impart.HTTPError{http.StatusFound, "/reset"} + + if !app.cfg.Email.Enabled() { + // Email isn't configured, so there's nothing to do; send back to the reset form, where they'll get an explanation + return returnLoc + } + + ip := spam.GetIP(r) + alias := r.FormValue("alias") + + u, err := app.db.GetUserForAuth(alias) + if err != nil { + if strings.IndexAny(alias, "@") > 0 { + addSessionFlash(app, w, r, ErrUserNotFoundEmail.Message, nil) + return returnLoc + } + addSessionFlash(app, w, r, ErrUserNotFound.Message, nil) + return returnLoc + } + if u.IsAdmin() { + // Prevent any reset emails on admin accounts + log.Error("Admin reset attempt", `Someone just tried to reset the password for an admin (ID %d - %s). IP address: %s`, u.ID, u.Username, ip) + return returnLoc + } + if u.Email.String == "" { + err := impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Please contact us (" + app.cfg.App.Host + "/contact) to reset your password."} + addSessionFlash(app, w, r, err.Message, nil) + return returnLoc + } + if isSet, _ := app.db.IsUserPassSet(u.ID); !isSet { + err = loginViaEmail(app, u.Username, "/me/settings") + if err != nil { + return err + } + addSessionFlash(app, w, r, "We've emailed you a link to log in with.", nil) + return returnLoc + } + + token, err := app.db.CreatePasswordResetToken(u.ID) + if err != nil { + log.Error("Error resetting password: %s", err) + addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil) + return returnLoc + } + + err = emailPasswordReset(app, u.EmailClear(app.keys), token) + if err != nil { + log.Error("Error emailing password reset: %s", err) + addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil) + return returnLoc + } + + addSessionFlash(app, w, r, "We sent an email to the address associated with this account.", nil) + returnLoc.Message += "?sent=1" + return returnLoc +} + +func emailPasswordReset(app *App, toEmail, token string) error { + // Send email + gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) + footerPara := "Didn't request this password reset? Your account is still safe, and you can safely ignore this email." + + plainMsg := fmt.Sprintf("We received a request to reset your password on %s. Please click the following link to continue (or copy and paste it into your browser): %s/reset?t=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara) + m := mailgun.NewMessage(app.cfg.App.SiteName+" ", "Reset Your "+app.cfg.App.SiteName+" Password", plainMsg, fmt.Sprintf("<%s>", toEmail)) + m.AddTag("Password Reset") + m.SetHtml(fmt.Sprintf(` + +
+

%s

+

We received a request to reset your password on %s. Please click the following link to continue:

+

Reset your password

+

%s

+
+ +`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara)) + _, _, err := gun.Send(m) + return err +} + +func loginViaEmail(app *App, alias, redirectTo string) error { + if !app.cfg.Email.Enabled() { + return fmt.Errorf("EMAIL ISN'T CONFIGURED on this server") + } + + // Make sure user has added an email + // TODO: create a new func to just get user's email; "ForAuth" doesn't match here + u, _ := app.db.GetUserForAuth(alias) + if u == nil { + if strings.IndexAny(alias, "@") > 0 { + return ErrUserNotFoundEmail + } + return ErrUserNotFound + } + if u.Email.String == "" { + return impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Log in with password, instead."} + } + + // Generate one-time login token + t, err := app.db.GetTemporaryOneTimeAccessToken(u.ID, 60*15, true) + if err != nil { + log.Error("Unable to generate token for email login: %s", err) + return impart.HTTPError{http.StatusInternalServerError, "Unable to generate token."} + } + + // Send email + gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) + toEmail := u.EmailClear(app.keys) + footerPara := "This link will only work once and expires in 15 minutes. Didn't ask us to log in? You can safely ignore this email." + + plainMsg := fmt.Sprintf("Log in to %s here: %s/login?to=%s&with=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, footerPara) + m := mailgun.NewMessage(app.cfg.App.SiteName+" ", "Log in to "+app.cfg.App.SiteName, plainMsg, fmt.Sprintf("<%s>", toEmail)) + m.AddTag("Email Login") + + m.SetHtml(fmt.Sprintf(` + +
+

%s

+

Log in to %s here.

+

%s

+
+ +`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, app.cfg.App.SiteName, footerPara)) + _, _, err = gun.Send(m) + + return err +} + func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error { session, err := app.sessionStore.Get(r, "t") if err != nil { diff --git a/account_import.go b/account_import.go index b34f3a7..656852f 100644 --- a/account_import.go +++ b/account_import.go @@ -5,7 +5,6 @@ import ( "fmt" "html/template" "io" - "io/ioutil" "net/http" "os" "path/filepath" @@ -100,7 +99,7 @@ func handleImport(app *App, u *User, w http.ResponseWriter, r *http.Request) err } defer file.Close() - tempFile, err := ioutil.TempFile("", "post-upload-*.txt") + tempFile, err := os.CreateTemp("", "post-upload-*.txt") if err != nil { fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename)) log.Error("import file: create temp file %s: %v", formFile.Filename, err) diff --git a/activitypub.go b/activitypub.go index efc34f3..02fb9e7 100644 --- a/activitypub.go +++ b/activitypub.go @@ -17,22 +17,25 @@ import ( "encoding/base64" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "net/http/httputil" "net/url" "path/filepath" "strconv" + "strings" "time" "github.com/gorilla/mux" "github.com/writeas/activity/streams" + "github.com/writeas/activityserve" "github.com/writeas/httpsig" "github.com/writeas/impart" "github.com/writeas/web-core/activitypub" "github.com/writeas/web-core/activitystreams" "github.com/writeas/web-core/id" "github.com/writeas/web-core/log" + "github.com/writeas/web-core/silobridge" ) const ( @@ -60,6 +63,7 @@ type RemoteUser struct { ActorID string Inbox string SharedInbox string + URL string Handle string } @@ -452,7 +456,7 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request followerID = remoteUser.ID } else { // Add follower locally, since it wasn't found before - res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox) VALUES (?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox) + res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url) VALUES (?, ?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox, fullActor.URL) if err != nil { // if duplicate key, res will be nil and panic on // res.LastInsertId below @@ -549,7 +553,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m defer resp.Body.Close() } - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { return err } @@ -601,7 +605,7 @@ func resolveIRI(hostName, url string) ([]byte, error) { defer resp.Body.Close() } - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } @@ -644,10 +648,7 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error { for si, instFolls := range inboxes { na.CC = []string{} - for _, f := range instFolls { - na.CC = append(na.CC, f) - } - + na.CC = append(na.CC, instFolls...) da := activitystreams.NewDeleteActivity(na) // Make the ID unique to ensure it works in Pleroma // See: https://git.pleroma.social/pleroma/pleroma/issues/1481 @@ -713,9 +714,7 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error { // add all followers from that instance // to the CC field na.CC = []string{} - for _, f := range instFolls { - na.CC = append(na.CC, f) - } + na.CC = append(na.CC, instFolls...) // create a new "Create" activity // with our article as object if isUpdate { @@ -764,8 +763,8 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error { func getRemoteUser(app *App, actorID string) (*RemoteUser, error) { u := RemoteUser{ActorID: actorID} - var handle sql.NullString - err := app.db.QueryRow("SELECT id, inbox, shared_inbox, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &handle) + var urlVal, handle sql.NullString + err := app.db.QueryRow("SELECT id, inbox, shared_inbox, url, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &urlVal, &handle) switch { case err == sql.ErrNoRows: return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."} @@ -774,6 +773,7 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) { return nil, err } + u.URL = urlVal.String u.Handle = handle.String return &u, nil @@ -783,7 +783,8 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) { // from the @user@server.tld handle func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) { u := RemoteUser{Handle: handle} - err := app.db.QueryRow("SELECT id, actor_id, inbox, shared_inbox FROM remoteusers WHERE handle = ?", handle).Scan(&u.ID, &u.ActorID, &u.Inbox, &u.SharedInbox) + var urlVal sql.NullString + err := app.db.QueryRow("SELECT id, actor_id, inbox, shared_inbox, url FROM remoteusers WHERE handle = ?", handle).Scan(&u.ID, &u.ActorID, &u.Inbox, &u.SharedInbox, &urlVal) switch { case err == sql.ErrNoRows: return nil, ErrRemoteUserNotFound @@ -791,6 +792,7 @@ func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) { log.Error("Couldn't get remote user %s: %v", handle, err) return nil, err } + u.URL = urlVal.String return &u, nil } @@ -824,6 +826,69 @@ func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser, return actor, remoteUser, nil } +func GetProfileURLFromHandle(app *App, handle string) (string, error) { + handle = strings.TrimLeft(handle, "@") + actorIRI := "" + parts := strings.Split(handle, "@") + if len(parts) != 2 { + return "", fmt.Errorf("invalid handle format") + } + domain := parts[1] + + // Check non-AP instances + if siloProfileURL := silobridge.Profile(parts[0], domain); siloProfileURL != "" { + return siloProfileURL, nil + } + + remoteUser, err := getRemoteUserFromHandle(app, handle) + if err != nil { + // can't find using handle in the table but the table may already have this user without + // handle from a previous version + // TODO: Make this determination. We should know whether a user exists without a handle, or doesn't exist at all + actorIRI = RemoteLookup(handle) + _, errRemoteUser := getRemoteUser(app, actorIRI) + // if it exists then we need to update the handle + if errRemoteUser == nil { + _, err := app.db.Exec("UPDATE remoteusers SET handle = ? WHERE actor_id = ?", handle, actorIRI) + if err != nil { + log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI) + } + } else { + // this probably means we don't have the user in the table so let's try to insert it + // here we need to ask the server for the inboxes + remoteActor, err := activityserve.NewRemoteActor(actorIRI) + if err != nil { + log.Error("Couldn't fetch remote actor: %v", err) + } + if debugging { + log.Info("Got remote actor: %s %s %s %s %s", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), remoteActor.URL(), handle) + } + _, err = app.db.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url, handle) VALUES(?, ?, ?, ?, ?)", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), remoteActor.URL(), handle) + if err != nil { + log.Error("Couldn't insert remote user: %v", err) + return "", err + } + actorIRI = remoteActor.URL() + } + } else if remoteUser.URL == "" { + log.Info("Remote user %s URL empty, fetching", remoteUser.ActorID) + newRemoteActor, err := activityserve.NewRemoteActor(remoteUser.ActorID) + if err != nil { + log.Error("Couldn't fetch remote actor: %v", err) + } else { + _, err := app.db.Exec("UPDATE remoteusers SET url = ? WHERE actor_id = ?", newRemoteActor.URL(), remoteUser.ActorID) + if err != nil { + log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI) + } else { + actorIRI = newRemoteActor.URL() + } + } + } else { + actorIRI = remoteUser.URL + } + return actorIRI, nil +} + // unmarshal actor normalizes the actor response to conform to // the type Person from github.com/writeas/web-core/activitysteams // diff --git a/admin.go b/admin.go index 6408cfe..258d0b8 100644 --- a/admin.go +++ b/admin.go @@ -13,6 +13,7 @@ package writefreely import ( "database/sql" "fmt" + "html/template" "net/http" "runtime" "strconv" @@ -102,13 +103,16 @@ func NewAdminPage(app *App) *AdminPage { return ap } -func (c instanceContent) UpdatedFriendly() string { +func (c instanceContent) UpdatedFriendly() template.HTML { /* // TODO: accept a locale in this method and use that for the format var loc monday.Locale = monday.LocaleEnUS return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc) */ - return c.Updated.Format("January 2, 2006, 3:04 PM") + if c.Updated.IsZero() { + return "Never" + } + return template.HTML(c.Updated.Format("January 2, 2006, 3:04 PM")) } func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error { @@ -426,9 +430,9 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ } // Add in default pages - var hasAbout, hasPrivacy bool + var hasAbout, hasContact, hasPrivacy bool for i, c := range p.Pages { - if hasAbout && hasPrivacy { + if hasAbout && hasContact && hasPrivacy { break } if c.ID == "about" { @@ -436,6 +440,11 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ if !c.Title.Valid { p.Pages[i].Title = defaultAboutTitle(app.cfg) } + } else if c.ID == "contact" { + hasContact = true + if !c.Title.Valid { + p.Pages[i].Title = defaultContactTitle() + } } else if c.ID == "privacy" { hasPrivacy = true if !c.Title.Valid { @@ -451,6 +460,13 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ Updated: defaultPageUpdatedTime, }) } + if !hasContact { + p.Pages = append(p.Pages, &instanceContent{ + ID: "contact", + Title: defaultContactTitle(), + Content: defaultContactPage(app), + }) + } if !hasPrivacy { p.Pages = append(p.Pages, &instanceContent{ ID: "privacy", @@ -489,6 +505,8 @@ func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Reque // Get pre-defined pages, or select slug if slug == "about" { p.Content, err = getAboutPage(app) + } else if slug == "contact" { + p.Content, err = getContactPage(app) } else if slug == "privacy" { p.Content, err = getPrivacyPage(app) } else if slug == "landing" { @@ -523,7 +541,7 @@ func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Req id := vars["page"] // Validate - if id != "about" && id != "privacy" && id != "landing" && id != "reader" { + if id != "about" && id != "contact" && id != "privacy" && id != "landing" && id != "reader" { return impart.HTTPError{http.StatusNotFound, "No such page."} } diff --git a/app.go b/app.go index c2989cb..0ddbaec 100644 --- a/app.go +++ b/app.go @@ -15,7 +15,7 @@ import ( "database/sql" "fmt" "html/template" - "io/ioutil" + "net" "net/http" "net/url" "os" @@ -56,7 +56,7 @@ var ( debugging bool // Software version can be set from git env using -ldflags - softwareVer = "0.13.2" + softwareVer = "0.14.0" // DEPRECATED VARS isSingleUser bool @@ -174,7 +174,7 @@ func (app *App) LoadKeys() error { executable = filepath.Base(executable) } - app.keys.EmailKey, err = ioutil.ReadFile(emailKeyPath) + app.keys.EmailKey, err = os.ReadFile(emailKeyPath) if err != nil { return err } @@ -182,7 +182,7 @@ func (app *App) LoadKeys() error { if debugging { log.Info(" %s", cookieAuthKeyPath) } - app.keys.CookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath) + app.keys.CookieAuthKey, err = os.ReadFile(cookieAuthKeyPath) if err != nil { return err } @@ -190,7 +190,7 @@ func (app *App) LoadKeys() error { if debugging { log.Info(" %s", cookieKeyPath) } - app.keys.CookieKey, err = ioutil.ReadFile(cookieKeyPath) + app.keys.CookieKey, err = os.ReadFile(cookieKeyPath) if err != nil { return err } @@ -198,7 +198,7 @@ func (app *App) LoadKeys() error { if debugging { log.Info(" %s", csrfKeyPath) } - app.keys.CSRFKey, err = ioutil.ReadFile(csrfKeyPath) + app.keys.CSRFKey, err = os.ReadFile(csrfKeyPath) if err != nil { if os.IsNotExist(err) { log.Error(`Missing key: %s. @@ -315,7 +315,7 @@ func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *te }{ StaticPage: pageForReq(app, r), } - if r.URL.Path == "/about" || r.URL.Path == "/privacy" { + if r.URL.Path == "/about" || r.URL.Path == "/contact" || r.URL.Path == "/privacy" { var c *instanceContent var err error @@ -326,6 +326,12 @@ func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *te p.AboutStats = &InstanceStats{} p.AboutStats.NumPosts, _ = app.db.GetTotalPosts() p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections() + } else if r.URL.Path == "/contact" { + c, err = getContactPage(app) + if c.Updated.IsZero() { + // Page was never set up, so return 404 + return ErrPostNotFound + } } else { c, err = getPrivacyPage(app) } @@ -420,6 +426,17 @@ func Initialize(apper Apper, debug bool) (*App, error) { initActivityPub(apper.App()) + if apper.App().cfg.Email.Domain != "" || apper.App().cfg.Email.MailgunPrivate != "" { + if apper.App().cfg.Email.Domain == "" { + log.Error("[FAILED] Starting publish jobs queue: no [letters]domain config value set.") + } else if apper.App().cfg.Email.MailgunPrivate == "" { + log.Error("[FAILED] Starting publish jobs queue: no [letters]mailgun_private config value set.") + } else { + log.Info("Starting publish jobs queue...") + go startPublishJobsQueue(apper.App()) + } + } + // Handle local timeline, if enabled if apper.App().cfg.App.LocalTimeline { log.Info("Initializing local timeline...") @@ -508,9 +525,41 @@ requests. We recommend supplying a valid host name.`) err = http.ListenAndServeTLS(fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, r) } } else { - log.Info("Serving on http://%s:%d\n", bindAddress, app.cfg.Server.Port) + network := "tcp" + protocol := "http" + if strings.HasPrefix(bindAddress, "/") { + network = "unix" + protocol = "http+unix" + + // old sockets will remain after server closes; + // we need to delete them in order to open new ones + err = os.Remove(bindAddress) + if err != nil && !os.IsNotExist(err) { + log.Error("%s already exists but could not be removed: %v", bindAddress, err) + os.Exit(1) + } + } else { + bindAddress = fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port) + } + + log.Info("Serving on %s://%s", protocol, bindAddress) log.Info("---") - err = http.ListenAndServe(fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port), r) + listener, err := net.Listen(network, bindAddress) + if err != nil { + log.Error("Could not bind to address: %v", err) + os.Exit(1) + } + + if network == "unix" { + err = os.Chmod(bindAddress, 0o666) + if err != nil { + log.Error("Could not update socket permissions: %v", err) + os.Exit(1) + } + } + + defer listener.Close() + err = http.Serve(listener, r) } if err != nil { log.Error("Unable to start: %v", err) @@ -534,8 +583,8 @@ func (app *App) InitDecoder() { // tests the connection. func ConnectToDatabase(app *App) error { // Check database configuration - if app.cfg.Database.Type == driverMySQL && (app.cfg.Database.User == "" || app.cfg.Database.Password == "") { - return fmt.Errorf("Database user or password not set.") + if app.cfg.Database.Type == driverMySQL && app.cfg.Database.User == "" { + return fmt.Errorf("Database user not set.") } if app.cfg.Database.Host == "" { app.cfg.Database.Host = "localhost" @@ -817,6 +866,16 @@ func connectToDatabase(app *App) { func shutdown(app *App) { log.Info("Closing database connection...") app.db.Close() + if strings.HasPrefix(app.cfg.Server.Bind, "/") { + // Clean up socket + log.Info("Removing socket file...") + err := os.Remove(app.cfg.Server.Bind) + if err != nil { + log.Error("Unable to remove socket: %s", err) + os.Exit(1) + } + log.Info("Success.") + } } // CreateUser creates a new admin or normal user from the given credentials. diff --git a/author/author.go b/author/author.go index 9c1fec9..a95eb61 100644 --- a/author/author.go +++ b/author/author.go @@ -11,6 +11,7 @@ package author import ( + "github.com/writeas/web-core/log" "github.com/writefreely/writefreely/config" "os" "path/filepath" @@ -113,10 +114,17 @@ func IsValidUsername(cfg *config.Config, username string) bool { // Username is invalid if page with the same name exists. So traverse // available pages, adding them to reservedUsernames map that'll be checked // later. - filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, "pages"), func(path string, i os.FileInfo, err error) error { + err := filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, "pages"), func(path string, i os.FileInfo, err error) error { + if err != nil { + return err + } reservedUsernames[i.Name()] = true return nil }) + if err != nil { + log.Error("[IMPORTANT WARNING]: Could not determine IsValidUsername! %s", err) + return false + } // Username is invalid if it is reserved! if _, reserved := reservedUsernames[username]; reserved { diff --git a/collections.go b/collections.go index 6ded4c3..73acf95 100644 --- a/collections.go +++ b/collections.go @@ -28,14 +28,18 @@ import ( "github.com/writeas/web-core/activitystreams" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/bots" + "github.com/writeas/web-core/i18n" "github.com/writeas/web-core/log" waposts "github.com/writeas/web-core/posts" "github.com/writefreely/writefreely/author" "github.com/writefreely/writefreely/config" "github.com/writefreely/writefreely/page" + "github.com/writefreely/writefreely/spam" "golang.org/x/net/idna" ) +const collAttrLetterReplyTo = "letter_reply_to" + type ( // TODO: add Direction to db // TODO: add Language to db @@ -58,6 +62,7 @@ type ( URL string `json:"url,omitempty"` Monetization string `json:"monetization_pointer,omitempty"` + Verification string `json:"verification_link"` db *datastore hostName string @@ -72,11 +77,20 @@ type ( DisplayCollection struct { *CollectionObj Prefix string + NavSuffix string IsTopLevel bool CurrentPage int TotalPages int Silenced bool } + + CollectionNav struct { + *Collection + Path string + SingleUser bool + CanPost bool + } + SubmittedCollection struct { // Data used for updating a given collection ID int64 @@ -87,6 +101,7 @@ type ( Privacy int `schema:"privacy" json:"privacy"` Pass string `schema:"password" json:"password"` MathJax bool `schema:"mathjax" json:"mathjax"` + EmailSubs bool `schema:"email_subs" json:"email_subs"` Handle string `schema:"handle" json:"handle"` // Actual collection values updated in the DB @@ -97,6 +112,8 @@ type ( Script *sql.NullString `schema:"script" json:"script"` Signature *sql.NullString `schema:"signature" json:"signature"` Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"` + Verification *string `schema:"verification_link" json:"verification_link"` + LetterReply *string `schema:"letter_reply" json:"letter_reply"` Visibility *int `schema:"visibility" json:"public"` Format *sql.NullString `schema:"format" json:"format"` } @@ -258,16 +275,16 @@ func (c *Collection) RedirectingCanonicalURL(isRedir bool) string { // PrevPageURL provides a full URL for the previous page of collection posts, // returning a /page/N result for pages >1 -func (c *Collection) PrevPageURL(prefix string, n int, tl bool) string { +func (c *Collection) PrevPageURL(prefix, navSuffix string, n int, tl bool) string { u := "" if n == 2 { // Previous page is 1; no need for /page/ prefix if prefix == "" { - u = "/" + u = navSuffix + "/" } // Else leave off trailing slash } else { - u = fmt.Sprintf("/page/%d", n-1) + u = fmt.Sprintf("%s/page/%d", navSuffix, n-1) } if tl { @@ -277,11 +294,12 @@ func (c *Collection) PrevPageURL(prefix string, n int, tl bool) string { } // NextPageURL provides a full URL for the next page of collection posts -func (c *Collection) NextPageURL(prefix string, n int, tl bool) string { +func (c *Collection) NextPageURL(prefix, navSuffix string, n int, tl bool) string { + if tl { - return fmt.Sprintf("/page/%d", n+1) + return fmt.Sprintf("%s/page/%d", navSuffix, n+1) } - return fmt.Sprintf("/%s%s/page/%d", prefix, c.Alias, n+1) + return fmt.Sprintf("/%s%s%s/page/%d", prefix, c.Alias, navSuffix, n+1) } func (c *Collection) DisplayTitle() string { @@ -355,6 +373,10 @@ func (c *Collection) RenderMathJax() bool { return c.db.CollectionHasAttribute(c.ID, "render_mathjax") } +func (c *Collection) EmailSubsEnabled() bool { + return c.db.CollectionHasAttribute(c.ID, "email_subs") +} + func (c *Collection) MonetizationURL() string { if c.Monetization == "" { return "" @@ -366,6 +388,16 @@ func (c CollectionPage) DisplayMonetization() string { return displayMonetization(c.Monetization, c.Alias) } +func (c *DisplayCollection) Direction() string { + if c.Language == "" { + return "auto" + } + if i18n.LangIsRTL(c.Language) { + return "rtl" + } + return "ltr" +} + func newCollection(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) alias := r.FormValue("alias") @@ -475,8 +507,7 @@ func apiCheckCollectionPermissions(app *App, r *http.Request, c *Collection) (in // fetchCollection handles the API endpoint for retrieving collection data. func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error { - accept := r.Header.Get("Accept") - if strings.Contains(accept, "application/activity+json") { + if IsActivityPubRequest(r) { return handleFetchCollectionActivities(app, w, r) } @@ -577,18 +608,46 @@ type CollectionPage struct { IsWelcome bool IsOwner bool IsCollLoggedIn bool + Honeypot string + IsSubscriber bool CanPin bool Username string Monetization string + Flash template.HTML Collections *[]Collection PinnedPosts *[]PublicPost - IsAdmin bool - CanInvite bool + + IsAdmin bool + CanInvite bool // Helper field for Chorus mode CollAlias string } +type TagCollectionPage struct { + CollectionPage + Tag string +} + +func (tcp TagCollectionPage) PrevPageURL(prefix string, n int, tl bool) string { + u := fmt.Sprintf("/tag:%s", tcp.Tag) + if n > 2 { + u += fmt.Sprintf("/page/%d", n-1) + } + if tl { + return u + } + return "/" + prefix + tcp.Alias + u + +} + +func (tcp TagCollectionPage) NextPageURL(prefix string, n int, tl bool) string { + if tl { + return fmt.Sprintf("/tag:%s/page/%d", tcp.Tag, n+1) + } + return fmt.Sprintf("/%s%s/tag:%s/page/%d", prefix, tcp.Alias, tcp.Tag, n+1) +} + func NewCollectionObj(c *Collection) *CollectionObj { return &CollectionObj{ Collection: *c, @@ -794,7 +853,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro } // Serve ActivityStreams data now, if requested - if strings.Contains(r.Header.Get("Accept"), "application/activity+json") { + if IsActivityPubRequest(r) { ac := c.PersonObject() ac.Context = []interface{}{activitystreams.Namespace} setCacheControl(w, apCacheTime) @@ -823,14 +882,20 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro StaticPage: pageForReq(app, r), IsCustomDomain: cr.isCustomDomain, IsWelcome: r.FormValue("greeting") != "", + Honeypot: spam.HoneypotFieldName(), CollAlias: c.Alias, } + flashes, _ := getSessionFlashes(app, w, r, nil) + for _, f := range flashes { + displayPage.Flash = template.HTML(f) + } displayPage.IsAdmin = u != nil && u.IsAdmin() displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin) var owner *User if u != nil { displayPage.Username = u.Username displayPage.IsOwner = u.ID == coll.OwnerID + displayPage.IsSubscriber = u.IsEmailSubscriber(app, coll.ID) if displayPage.IsOwner { // Add in needed information for users viewing their own collection owner = u @@ -930,16 +995,29 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e coll := newDisplayCollection(c, cr, page) + taggedPostIDs, err := app.db.GetAllPostsTaggedIDs(c, tag, cr.isCollOwner) + if err != nil { + return err + } + + ttlPosts := len(taggedPostIDs) + pagePosts := coll.Format.PostsPerPage() + coll.TotalPages = int(math.Ceil(float64(ttlPosts) / float64(pagePosts))) + if coll.TotalPages > 0 && page > coll.TotalPages { + redirURL := fmt.Sprintf("/page/%d", coll.TotalPages) + if !app.cfg.App.SingleUser { + redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL) + } + return impart.HTTPError{http.StatusFound, redirURL} + } + coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, page, cr.isCollOwner) if coll.Posts != nil && len(*coll.Posts) == 0 { return ErrCollectionPageNotFound } // Serve collection - displayPage := struct { - CollectionPage - Tag string - }{ + displayPage := TagCollectionPage{ CollectionPage: CollectionPage{ DisplayCollection: coll, StaticPage: pageForReq(app, r), @@ -991,6 +1069,111 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e return nil } +func handleViewCollectionLang(app *App, w http.ResponseWriter, r *http.Request) error { + vars := mux.Vars(r) + lang := vars["lang"] + + cr := &collectionReq{} + err := processCollectionRequest(cr, vars, w, r) + if err != nil { + return err + } + + u, err := checkUserForCollection(app, cr, r, false) + if err != nil { + return err + } + + page := getCollectionPage(vars) + + c, err := processCollectionPermissions(app, cr, u, w, r) + if c == nil || err != nil { + return err + } + + coll := newDisplayCollection(c, cr, page) + coll.Language = lang + coll.NavSuffix = fmt.Sprintf("/lang:%s", lang) + + ttlPosts, err := app.db.GetCollLangTotalPosts(coll.ID, lang) + if err != nil { + log.Error("Unable to getCollLangTotalPosts: %s", err) + } + pagePosts := coll.Format.PostsPerPage() + coll.TotalPages = int(math.Ceil(float64(ttlPosts) / float64(pagePosts))) + if coll.TotalPages > 0 && page > coll.TotalPages { + redirURL := fmt.Sprintf("/lang:%s/page/%d", lang, coll.TotalPages) + if !app.cfg.App.SingleUser { + redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL) + } + return impart.HTTPError{http.StatusFound, redirURL} + } + + coll.Posts, _ = app.db.GetLangPosts(app.cfg, c, lang, page, cr.isCollOwner) + if err != nil { + return ErrCollectionPageNotFound + } + + // Serve collection + displayPage := struct { + CollectionPage + Tag string + }{ + CollectionPage: CollectionPage{ + DisplayCollection: coll, + StaticPage: pageForReq(app, r), + IsCustomDomain: cr.isCustomDomain, + }, + Tag: lang, + } + var owner *User + if u != nil { + displayPage.Username = u.Username + displayPage.IsOwner = u.ID == coll.OwnerID + if displayPage.IsOwner { + // Add in needed information for users viewing their own collection + owner = u + displayPage.CanPin = true + + pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host) + if err != nil { + log.Error("unable to fetch collections: %v", err) + } + displayPage.Collections = pubColls + } + } + isOwner := owner != nil + if !isOwner { + // Current user doesn't own collection; retrieve owner information + owner, err = app.db.GetUserByID(coll.OwnerID) + if err != nil { + // Log the error and just continue + log.Error("Error getting user for collection: %v", err) + } + if owner.IsSilenced() { + return ErrCollectionNotFound + } + } + displayPage.Silenced = owner != nil && owner.IsSilenced() + displayPage.Owner = owner + coll.Owner = displayPage.Owner + // 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 { + collTmpl = "chorus-collection" + } + err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage) + if err != nil { + log.Error("Unable to render collection lang page: %v", err) + } + + return nil +} + func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) slug := vars["slug"] @@ -1075,7 +1258,7 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error } } - err = app.db.UpdateCollection(&c, collAlias) + err = app.db.UpdateCollection(app, &c, collAlias) if err != nil { if err, ok := err.(impart.HTTPError); ok { if reqJSON { diff --git a/config/config.go b/config/config.go index 2065ddf..1afd5f3 100644 --- a/config/config.go +++ b/config/config.go @@ -170,11 +170,17 @@ type ( DisablePasswordAuth bool `ini:"disable_password_auth"` } + EmailCfg struct { + Domain string `ini:"domain"` + MailgunPrivate string `ini:"mailgun_private"` + } + // Config holds the complete configuration for running a writefreely instance Config struct { Server ServerCfg `ini:"server"` Database DatabaseCfg `ini:"database"` App AppCfg `ini:"app"` + Email EmailCfg `ini:"email"` SlackOauth SlackOauthCfg `ini:"oauth.slack"` WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"` GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"` @@ -235,6 +241,10 @@ func (ac *AppCfg) LandingPath() string { return ac.Landing } +func (lc EmailCfg) Enabled() bool { + return lc.Domain != "" && lc.MailgunPrivate != "" +} + func (ac AppCfg) SignupPath() string { if !ac.OpenRegistration { return "" diff --git a/config/setup.go b/config/setup.go index 9fddd5a..b00392d 100644 --- a/config/setup.go +++ b/config/setup.go @@ -57,7 +57,7 @@ func Configure(fname string, configSections string) (*SetupData, error) { Success: "{{ . | bold | faint }}: ", } selTmpls := &promptui.SelectTemplates{ - Selected: fmt.Sprintf(`{{.Label}} {{ . | faint }}`), + Selected: `{{.Label}} {{ . | faint }}`, } var selPrompt promptui.Select diff --git a/database.go b/database.go index 8bd5a43..2614523 100644 --- a/database.go +++ b/database.go @@ -14,9 +14,11 @@ import ( "context" "database/sql" "fmt" + "github.com/go-sql-driver/mysql" "github.com/writeas/web-core/silobridge" wf_db "github.com/writefreely/writefreely/db" "net/http" + "net/url" "strings" "time" @@ -95,7 +97,7 @@ type writestore interface { GetCollection(alias string) (*Collection, error) GetCollectionForPad(alias string) (*Collection, error) GetCollectionByID(id int64) (*Collection, error) - UpdateCollection(c *SubmittedCollection, alias string) error + UpdateCollection(app *App, c *SubmittedCollection, alias string) error DeleteCollection(alias string, userID int64) error UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error @@ -113,6 +115,7 @@ type writestore interface { GetPostsCount(c *CollectionObj, includeFuture bool) GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error) + GetAllPostsTaggedIDs(c *Collection, tag string, includeFuture bool) ([]string, error) GetPostsTagged(cfg *config.Config, c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error) GetAPFollowers(c *Collection) (*[]RemoteUser, error) @@ -171,6 +174,13 @@ func (db *datastore) upsert(indexedCols ...string) string { return "ON DUPLICATE KEY UPDATE" } +func (db *datastore) dateAdd(l int, unit string) string { + if db.driverName == driverSQLite { + return fmt.Sprintf("DATETIME('now', '%d %s')", l, unit) + } + return fmt.Sprintf("DATE_ADD(NOW(), INTERVAL %d %s)", l, unit) +} + func (db *datastore) dateSub(l int, unit string) string { if db.driverName == driverSQLite { return fmt.Sprintf("DATETIME('now', '-%d %s')", l, unit) @@ -564,7 +574,7 @@ func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int, expirationVal := "NULL" if validSecs > 0 { - expirationVal = fmt.Sprintf("DATE_ADD("+db.now()+", INTERVAL %d SECOND)", validSecs) + expirationVal = db.dateAdd(validSecs, "SECOND") } _, err = db.Exec("INSERT INTO accesstokens (token, user_id, one_time, expires) VALUES (?, ?, ?, "+expirationVal+")", string(binTok), userID, oneTime) @@ -576,6 +586,37 @@ func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int, return u.String(), nil } +func (db *datastore) CreatePasswordResetToken(userID int64) (string, error) { + t := id.Generate62RandomString(32) + + _, err := db.Exec("INSERT INTO password_resets (user_id, token, used, created) VALUES (?, ?, 0, "+db.now()+")", userID, t) + if err != nil { + log.Error("Couldn't INSERT password_resets: %v", err) + return "", err + } + + return t, nil +} + +func (db *datastore) GetUserFromPasswordReset(token string) int64 { + var userID int64 + err := db.QueryRow("SELECT user_id FROM password_resets WHERE token = ? AND used = 0 AND created > "+db.dateSub(3, "HOUR"), token).Scan(&userID) + if err != nil { + return 0 + } + return userID +} + +func (db *datastore) ConsumePasswordResetToken(t string) error { + _, err := db.Exec("UPDATE password_resets SET used = 1 WHERE token = ?", t) + if err != nil { + log.Error("Couldn't UPDATE password_resets: %v", err) + return err + } + + return nil +} + func (db *datastore) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName string) (*PublicPost, error) { var userID, collID int64 = -1, -1 var coll *Collection @@ -814,6 +855,7 @@ func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Coll c.Format = format.String c.Public = c.IsPublic() c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer") + c.Verification = db.GetCollectionAttribute(c.ID, "verification_link") c.db = db @@ -850,7 +892,7 @@ func (db *datastore) GetCollectionFromDomain(host string) (*Collection, error) { return db.GetCollectionBy("host = ?", host) } -func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) error { +func (db *datastore) UpdateCollection(app *App, c *SubmittedCollection, alias string) error { q := query.NewUpdate(). SetStringPtr(c.Title, "title"). SetStringPtr(c.Description, "description"). @@ -909,6 +951,44 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro } } + // Update Verification link value + if c.Verification != nil { + skipUpdate := false + if *c.Verification != "" { + // Strip away any excess spaces + trimmed := strings.TrimSpace(*c.Verification) + if strings.HasPrefix(trimmed, "@") && strings.Count(trimmed, "@") == 2 { + // This looks like a fediverse handle, so resolve profile URL + profileURL, err := GetProfileURLFromHandle(app, trimmed) + if err != nil || profileURL == "" { + log.Error("Couldn't find user %s: %v", trimmed, err) + skipUpdate = true + } else { + c.Verification = &profileURL + } + } else { + if !strings.HasPrefix(trimmed, "http") { + trimmed = "https://" + trimmed + } + vu, err := url.Parse(trimmed) + if err != nil { + // Value appears invalid, so don't update + skipUpdate = true + } else { + s := vu.String() + c.Verification = &s + } + } + } + if !skipUpdate { + err = db.SetCollectionAttribute(collID, "verification_link", *c.Verification) + if err != nil { + log.Error("Unable to insert verification_link value: %v", err) + return err + } + } + } + // Update Monetization value if c.Monetization != nil { skipUpdate := false @@ -932,6 +1012,40 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro } } + // Update EmailSub value + if c.EmailSubs { + err = db.SetCollectionAttribute(collID, "email_subs", "1") + if err != nil { + log.Error("Unable to insert email_subs value: %v", err) + return err + } + skipUpdate := false + if c.LetterReply != nil { + // Strip away any excess spaces + trimmed := strings.TrimSpace(*c.LetterReply) + // Only update value when it contains "@" + if strings.IndexRune(trimmed, '@') > 0 { + c.LetterReply = &trimmed + } else { + // Value appears invalid, so don't update + skipUpdate = true + } + if !skipUpdate { + err = db.SetCollectionAttribute(collID, collAttrLetterReplyTo, *c.LetterReply) + if err != nil { + log.Error("Unable to insert %s value: %v", collAttrLetterReplyTo, err) + return err + } + } + } + } else { + _, err = db.Exec("DELETE FROM collectionattributes WHERE collection_id = ? AND attribute = ?", collID, "email_subs") + if err != nil { + log.Error("Unable to delete email_subs value: %v", err) + return err + } + } + // Update rest of the collection data if q.Updates != "" { res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...) @@ -1195,6 +1309,51 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu return &posts, nil } +func (db *datastore) GetAllPostsTaggedIDs(c *Collection, tag string, includeFuture bool) ([]string, error) { + collID := c.ID + + cf := c.NewFormat() + order := "DESC" + if cf.Ascending() { + order = "ASC" + } + + timeCondition := "" + if !includeFuture { + timeCondition = "AND created <= " + db.now() + } + var rows *sql.Rows + var err error + if db.driverName == driverSQLite { + rows, err = db.Query("SELECT id FROM posts WHERE collection_id = ? AND LOWER(content) regexp ? "+timeCondition+" ORDER BY created "+order, collID, `.*#`+strings.ToLower(tag)+`\b.*`) + } else { + rows, err = db.Query("SELECT id FROM posts WHERE collection_id = ? AND LOWER(content) RLIKE ? "+timeCondition+" ORDER BY created "+order, collID, "#"+strings.ToLower(tag)+"[[:>:]]") + } + if err != nil { + log.Error("Failed selecting tagged posts: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve tagged collection posts."} + } + defer rows.Close() + + ids := []string{} + for rows.Next() { + var id string + err = rows.Scan(&id) + if err != nil { + log.Error("Failed scanning row: %v", err) + break + } + + ids = append(ids, id) + } + err = rows.Err() + if err != nil { + log.Error("Error after Next() on rows: %v", err) + } + + return ids, nil +} + // GetPostsTagged retrieves all posts on the given Collection that contain the // given tag. // It will return future posts if `includeFuture` is true. @@ -1260,6 +1419,74 @@ func (db *datastore) GetPostsTagged(cfg *config.Config, c *Collection, tag strin return &posts, nil } +func (db *datastore) GetCollLangTotalPosts(collID int64, lang string) (uint64, error) { + var articles uint64 + err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE collection_id = ? AND language = ? AND created <= "+db.now(), collID, lang).Scan(&articles) + if err != nil && err != sql.ErrNoRows { + log.Error("Couldn't get total lang posts count for collection %d: %v", collID, err) + return 0, err + } + return articles, nil +} + +func (db *datastore) GetLangPosts(cfg *config.Config, c *Collection, lang string, page int, includeFuture bool) (*[]PublicPost, error) { + collID := c.ID + + cf := c.NewFormat() + order := "DESC" + if cf.Ascending() { + order = "ASC" + } + + pagePosts := cf.PostsPerPage() + start := page*pagePosts - pagePosts + if page == 0 { + start = 0 + pagePosts = 1000 + } + + limitStr := "" + if page > 0 { + limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts) + } + timeCondition := "" + if !includeFuture { + timeCondition = "AND created <= " + db.now() + } + + rows, err := db.Query(`SELECT `+postCols+` +FROM posts +WHERE collection_id = ? AND language = ? `+timeCondition+` +ORDER BY created `+order+limitStr, collID, lang) + if err != nil { + log.Error("Failed selecting from posts: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts."} + } + defer rows.Close() + + // TODO: extract this common row scanning logic for queries using `postCols` + posts := []PublicPost{} + for rows.Next() { + p := &Post{} + err = rows.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content) + if err != nil { + log.Error("Failed scanning row: %v", err) + break + } + p.extractData() + p.augmentContent(c) + p.formatContent(cfg, c, includeFuture, false) + + posts = append(posts, p.processPost()) + } + err = rows.Err() + if err != nil { + log.Error("Error after Next() on rows: %v", err) + } + + return &posts, nil +} + func (db *datastore) GetAPFollowers(c *Collection) (*[]RemoteUser, error) { rows, err := db.Query("SELECT actor_id, inbox, shared_inbox FROM remotefollows f INNER JOIN remoteusers u ON f.remote_user_id = u.id WHERE collection_id = ?", c.ID) if err != nil { @@ -2228,7 +2455,7 @@ func (db *datastore) GetCollectionAttribute(id int64, attr string) string { } func (db *datastore) SetCollectionAttribute(id int64, attr, v string) error { - _, err := db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?)", id, attr, v) + _, err := db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) "+db.upsert("collection_id", "attribute")+" value = ?", id, attr, v, v) if err != nil { log.Error("Unable to INSERT into collectionattributes: %v", err) return err @@ -2765,6 +2992,7 @@ func handleFailedPostInsert(err error) error { return err } +// Deprecated: use GetProfileURLFromHandle() instead, which returns user-facing URL instead of actor_id func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, error) { handle = strings.TrimLeft(handle, "@") actorIRI := "" @@ -2813,3 +3041,247 @@ func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, } return actorIRI, nil } + +func (db *datastore) AddEmailSubscription(collID, userID int64, email string, confirmed bool) (*EmailSubscriber, error) { + friendlyChars := "0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz" + subID := id.GenerateRandomString(friendlyChars, 8) + token := id.GenerateRandomString(friendlyChars, 16) + emailVal := sql.NullString{ + String: email, + Valid: email != "", + } + userIDVal := sql.NullInt64{ + Int64: userID, + Valid: userID > 0, + } + + _, err := db.Exec("INSERT INTO emailsubscribers (id, collection_id, user_id, email, subscribed, token, confirmed) VALUES (?, ?, ?, ?, "+db.now()+", ?, ?)", subID, collID, userIDVal, emailVal, token, confirmed) + if err != nil { + if mysqlErr, ok := err.(*mysql.MySQLError); ok { + if mysqlErr.Number == mySQLErrDuplicateKey { + // Duplicate, so just return existing subscriber information + log.Info("Duplicate subscriber for email %s, user %d; returning existing subscriber", email, userID) + return db.FetchEmailSubscriber(email, userID, collID) + } + } + return nil, err + } + + return &EmailSubscriber{ + ID: subID, + CollID: collID, + UserID: userIDVal, + Email: emailVal, + Token: token, + }, nil +} + +func (db *datastore) IsEmailSubscriber(email string, userID, collID int64) bool { + var dummy int + var err error + if email != "" { + err = db.QueryRow("SELECT 1 FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID).Scan(&dummy) + } else { + err = db.QueryRow("SELECT 1 FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID).Scan(&dummy) + } + switch { + case err == sql.ErrNoRows: + return false + case err != nil: + return false + } + return true +} + +func (db *datastore) GetEmailSubscribers(collID int64, reqConfirmed bool) ([]*EmailSubscriber, error) { + cond := "" + if reqConfirmed { + cond = " AND confirmed = 1" + } + rows, err := db.Query(`SELECT s.id, collection_id, user_id, s.email, u.email, subscribed, token, confirmed, allow_export +FROM emailsubscribers s +LEFT JOIN users u + ON u.id = user_id +WHERE collection_id = ?`+cond+` +ORDER BY subscribed DESC`, collID) + if err != nil { + log.Error("Failed selecting email subscribers for collection %d: %v", collID, err) + return nil, err + } + defer rows.Close() + + var subs []*EmailSubscriber + for rows.Next() { + s := &EmailSubscriber{} + err = rows.Scan(&s.ID, &s.CollID, &s.UserID, &s.Email, &s.acctEmail, &s.Subscribed, &s.Token, &s.Confirmed, &s.AllowExport) + if err != nil { + log.Error("Failed scanning row from email subscribers: %v", err) + continue + } + subs = append(subs, s) + } + return subs, nil +} + +func (db *datastore) FetchEmailSubscriberEmail(subID, token string) (string, error) { + var email sql.NullString + // TODO: return user email if there's a user_id ? + err := db.QueryRow("SELECT email FROM emailsubscribers WHERE id = ? AND token = ?", subID, token).Scan(&email) + switch { + case err == sql.ErrNoRows: + return "", fmt.Errorf("Subscriber doesn't exist or token is invalid.") + case err != nil: + log.Error("Couldn't SELECT email from emailsubscribers: %v", err) + return "", fmt.Errorf("Something went very wrong.") + } + + return email.String, nil +} + +func (db *datastore) FetchEmailSubscriber(email string, userID, collID int64) (*EmailSubscriber, error) { + const emailSubCols = "id, collection_id, user_id, email, subscribed, token, confirmed, allow_export" + + s := &EmailSubscriber{} + var row *sql.Row + if email != "" { + row = db.QueryRow("SELECT "+emailSubCols+" FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID) + } else { + row = db.QueryRow("SELECT "+emailSubCols+" FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID) + } + err := row.Scan(&s.ID, &s.CollID, &s.UserID, &s.Email, &s.Subscribed, &s.Token, &s.Confirmed, &s.AllowExport) + switch { + case err == sql.ErrNoRows: + return nil, nil + case err != nil: + return nil, err + } + return s, nil +} + +func (db *datastore) DeleteEmailSubscriber(subID, token string) error { + res, err := db.Exec("DELETE FROM emailsubscribers WHERE id = ? AND token = ?", subID, token) + if err != nil { + return err + } + + rowsAffected, _ := res.RowsAffected() + if rowsAffected == 0 { + return impart.HTTPError{http.StatusNotFound, "Invalid token, or subscriber doesn't exist"} + } + return nil +} + +func (db *datastore) DeleteEmailSubscriberByUser(email string, userID, collID int64) error { + var res sql.Result + var err error + if email != "" { + res, err = db.Exec("DELETE FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID) + } else { + res, err = db.Exec("DELETE FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID) + } + if err != nil { + return err + } + + rowsAffected, _ := res.RowsAffected() + if rowsAffected == 0 { + return impart.HTTPError{http.StatusNotFound, "Subscriber doesn't exist"} + } + return nil +} + +func (db *datastore) UpdateSubscriberConfirmed(subID, token string) error { + email, err := db.FetchEmailSubscriberEmail(subID, token) + if err != nil { + log.Error("Didn't fetch email subscriber: %v", err) + return err + } + + // TODO: ensure all addresses with original name are also confirmed, e.g. matt+fake@write.as and matt@write.as are now confirmed + _, err = db.Exec("UPDATE emailsubscribers SET confirmed = 1 WHERE email = ?", email) + if err != nil { + log.Error("Could not update email subscriber confirmation status: %v", err) + return err + } + return nil +} + +func (db *datastore) IsSubscriberConfirmed(email string) bool { + var dummy int64 + err := db.QueryRow("SELECT 1 FROM emailsubscribers WHERE email = ? AND confirmed = 1", email).Scan(&dummy) + switch { + case err == sql.ErrNoRows: + return false + case err != nil: + log.Error("Couldn't SELECT in isSubscriberConfirmed: %v", err) + return false + } + + return true +} + +func (db *datastore) InsertJob(j *PostJob) error { + res, err := db.Exec("INSERT INTO publishjobs (post_id, action, delay) VALUES (?, ?, ?)", j.PostID, j.Action, j.Delay) + if err != nil { + return err + } + jobID, err := res.LastInsertId() + if err != nil { + log.Error("[jobs] Couldn't get last insert ID! %s", err) + } + log.Info("[jobs] Queued %s job #%d for post %s, delayed %d minutes", j.Action, jobID, j.PostID, j.Delay) + return nil +} + +func (db *datastore) UpdateJobForPost(postID string, delay int64) error { + _, err := db.Exec("UPDATE publishjobs SET delay = ? WHERE post_id = ?", delay, postID) + if err != nil { + return fmt.Errorf("Unable to update publish job: %s", err) + } + log.Info("Updated job for post %s: delay %d", postID, delay) + return nil +} + +func (db *datastore) DeleteJob(id int64) error { + _, err := db.Exec("DELETE FROM publishjobs WHERE id = ?", id) + if err != nil { + return err + } + log.Info("[job #%d] Deleted.", id) + return nil +} + +func (db *datastore) DeleteJobByPost(postID string) error { + _, err := db.Exec("DELETE FROM publishjobs WHERE post_id = ?", postID) + if err != nil { + return err + } + log.Info("[job] Deleted job for post %s", postID) + return nil +} + +func (db *datastore) GetJobsToRun(action string) ([]*PostJob, error) { + timeWhere := "created < DATE_SUB(NOW(), INTERVAL delay MINUTE) AND created > DATE_SUB(NOW(), INTERVAL delay + 5 MINUTE)" + if db.driverName == driverSQLite { + timeWhere = "created < DATETIME('now', '-' || delay || ' MINUTE') AND created > DATETIME('now', '-' || (delay+5) || ' MINUTE')" + } + rows, err := db.Query(`SELECT pj.id, post_id, action, delay + FROM publishjobs pj + INNER JOIN posts p + ON post_id = p.id + WHERE action = ? AND `+timeWhere+` + ORDER BY created ASC`, action) + if err != nil { + log.Error("Failed selecting from publishjobs: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve publish jobs."} + } + defer rows.Close() + + jobs := []*PostJob{} + for rows.Next() { + j := &PostJob{} + err = rows.Scan(&j.ID, &j.PostID, &j.Action, &j.Delay) + jobs = append(jobs, j) + } + return jobs, nil +} diff --git a/db/create.go b/db/create.go index 8728d5d..1e9e679 100644 --- a/db/create.go +++ b/db/create.go @@ -247,10 +247,7 @@ func (b *CreateTableSqlBuilder) ToSQL() (string, error) { } things = append(things, columnStr) } - for _, constraint := range b.Constraints { - things = append(things, constraint) - } - + things = append(things, b.Constraints...) if thingLen := len(things); thingLen > 0 { str.WriteString(" ( ") for i, thing := range things { diff --git a/email.go b/email.go new file mode 100644 index 0000000..da4590e --- /dev/null +++ b/email.go @@ -0,0 +1,462 @@ +/* + * Copyright © 2019-2021 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package writefreely + +import ( + "database/sql" + "encoding/json" + "fmt" + "html/template" + "net/http" + "strings" + "time" + + "github.com/aymerick/douceur/inliner" + "github.com/gorilla/mux" + "github.com/mailgun/mailgun-go" + stripmd "github.com/writeas/go-strip-markdown/v2" + "github.com/writeas/impart" + "github.com/writeas/web-core/data" + "github.com/writeas/web-core/log" + "github.com/writefreely/writefreely/key" + "github.com/writefreely/writefreely/spam" +) + +const ( + emailSendDelay = 15 +) + +type ( + SubmittedSubscription struct { + CollAlias string + UserID int64 + + Email string `schema:"email" json:"email"` + Web bool `schema:"web" json:"web"` + Slug string `schema:"slug" json:"slug"` + From string `schema:"from" json:"from"` + } + + EmailSubscriber struct { + ID string + CollID int64 + UserID sql.NullInt64 + Email sql.NullString + Subscribed time.Time + Token string + Confirmed bool + AllowExport bool + acctEmail sql.NullString + } +) + +func (es *EmailSubscriber) FinalEmail(keys *key.Keychain) string { + if !es.UserID.Valid || es.Email.Valid { + return es.Email.String + } + + decEmail, err := data.Decrypt(keys.EmailKey, []byte(es.acctEmail.String)) + if err != nil { + log.Error("Error decrypting user email: %v", err) + return "" + } + return string(decEmail) +} + +func (es *EmailSubscriber) SubscribedFriendly() string { + return es.Subscribed.Format("January 2, 2006") +} + +func handleCreateEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { + reqJSON := IsJSON(r) + vars := mux.Vars(r) + var err error + + ss := SubmittedSubscription{ + CollAlias: vars["alias"], + } + u := getUserSession(app, r) + if u != nil { + ss.UserID = u.ID + } + if reqJSON { + // Decode JSON request + decoder := json.NewDecoder(r.Body) + err = decoder.Decode(&ss) + if err != nil { + log.Error("Couldn't parse new subscription JSON request: %v\n", err) + return ErrBadJSON + } + } else { + err = r.ParseForm() + if err != nil { + log.Error("Couldn't parse new subscription form request: %v\n", err) + return ErrBadFormData + } + + err = app.formDecoder.Decode(&ss, r.PostForm) + if err != nil { + log.Error("Continuing, but error decoding new subscription form request: %v\n", err) + //return ErrBadFormData + } + } + + c, err := app.db.GetCollection(ss.CollAlias) + if err != nil { + log.Error("getCollection: %s", err) + return err + } + c.hostName = app.cfg.App.Host + + from := c.CanonicalURL() + isAuthorBanned, err := app.db.IsUserSilenced(c.OwnerID) + if isAuthorBanned { + log.Info("Author is silenced, so subscription is blocked.") + return impart.HTTPError{http.StatusFound, from} + } + + if ss.Web { + if u != nil && u.ID == c.OwnerID { + from = "/" + c.Alias + "/" + } + from += ss.Slug + } + + if r.FormValue(spam.HoneypotFieldName()) != "" || r.FormValue("fake_password") != "" { + log.Info("Honeypot field was filled out! Not subscribing.") + return impart.HTTPError{http.StatusFound, from} + } + + if ss.Email == "" && ss.UserID < 1 { + log.Info("No subscriber data. Not subscribing.") + return impart.HTTPError{http.StatusFound, from} + } + + confirmed := app.db.IsSubscriberConfirmed(ss.Email) + es, err := app.db.AddEmailSubscription(c.ID, ss.UserID, ss.Email, confirmed) + if err != nil { + log.Error("addEmailSubscription: %s", err) + return err + } + + // Send confirmation email if needed + if !confirmed { + err = sendSubConfirmEmail(app, c, ss.Email, es.ID, es.Token) + if err != nil { + log.Error("Failed to send subscription confirmation email: %s", err) + return err + } + } + + if ss.Web { + session, err := app.sessionStore.Get(r, userEmailCookieName) + if err != nil { + // The cookie should still save, even if there's an error. + // Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144 + log.Error("Getting user email cookie: %v; ignoring", err) + } + if confirmed { + addSessionFlash(app, w, r, "Subscribed. You'll now receive future blog posts via email.", nil) + } else { + addSessionFlash(app, w, r, "Please check your email and click the confirmation link to subscribe.", nil) + } + session.Values[userEmailCookieVal] = ss.Email + err = session.Save(r, w) + if err != nil { + log.Error("save email cookie: %s", err) + return err + } + + return impart.HTTPError{http.StatusFound, from} + } + return impart.WriteSuccess(w, "", http.StatusAccepted) +} + +func handleDeleteEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { + alias := collectionAliasFromReq(r) + + vars := mux.Vars(r) + subID := vars["subscriber"] + email := r.FormValue("email") + token := r.FormValue("t") + slug := r.FormValue("slug") + isWeb := r.Method == "GET" + + // Display collection if this is a collection + var c *Collection + var err error + if app.cfg.App.SingleUser { + c, err = app.db.GetCollectionByID(1) + } else { + c, err = app.db.GetCollection(alias) + } + if err != nil { + log.Error("Get collection: %s", err) + return err + } + + from := c.CanonicalURL() + + if subID != "" { + // User unsubscribing via email, so assume action is taken by either current + // user or not current user, and only use the request's information to + // satisfy this unsubscribe, i.e. subscriberID and token. + err = app.db.DeleteEmailSubscriber(subID, token) + } else { + // User unsubscribing through the web app, so assume action is taken by + // currently-auth'd user. + var userID int64 + u := getUserSession(app, r) + if u != nil { + // User is logged in + userID = u.ID + if userID == c.OwnerID { + from = "/" + c.Alias + "/" + } + } + if email == "" && userID <= 0 { + // Get email address from saved cookie + session, err := app.sessionStore.Get(r, userEmailCookieName) + if err != nil { + log.Error("Unable to get email cookie: %s", err) + } else { + email = session.Values[userEmailCookieVal].(string) + } + } + + if email == "" && userID <= 0 { + err = fmt.Errorf("No subscriber given.") + log.Error("Not deleting subscription: %s", err) + return err + } + + err = app.db.DeleteEmailSubscriberByUser(email, userID, c.ID) + } + if err != nil { + log.Error("Unable to delete subscriber: %v", err) + return err + } + + if isWeb { + from += slug + addSessionFlash(app, w, r, "Unsubscribed. You will no longer receive these blog posts via email.", nil) + return impart.HTTPError{http.StatusFound, from} + } + return impart.WriteSuccess(w, "", http.StatusAccepted) +} + +func handleConfirmEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { + alias := collectionAliasFromReq(r) + subID := mux.Vars(r)["subscriber"] + token := r.FormValue("t") + + var c *Collection + var err error + if app.cfg.App.SingleUser { + c, err = app.db.GetCollectionByID(1) + } else { + c, err = app.db.GetCollection(alias) + } + if err != nil { + log.Error("Get collection: %s", err) + return err + } + + from := c.CanonicalURL() + + err = app.db.UpdateSubscriberConfirmed(subID, token) + if err != nil { + addSessionFlash(app, w, r, err.Error(), nil) + return impart.HTTPError{http.StatusFound, from} + } + + addSessionFlash(app, w, r, "Confirmed! Thanks. Now you'll receive future blog posts via email.", nil) + return impart.HTTPError{http.StatusFound, from} +} + +func emailPost(app *App, p *PublicPost, collID int64) error { + p.augmentContent() + + // Do some shortcode replacement. + // Since the user is receiving this email, we can assume they're subscribed via email. + p.Content = strings.Replace(p.Content, "", `

You're subscribed to email updates.

`, -1) + + if p.HTMLContent == template.HTML("") { + p.formatContent(app.cfg, false, false) + } + p.augmentReadingDestination() + + title := p.Title.String + if title != "" { + title = p.Title.String + "\n\n" + } + plainMsg := title + "A new post from " + p.CanonicalURL(app.cfg.App.Host) + "\n\n" + stripmd.Strip(p.Content) + plainMsg += ` + +--------------------------------------------------------------------------------- + +Originally published on ` + p.Collection.DisplayTitle() + ` (` + p.Collection.CanonicalURL() + `), a blog you subscribe to. + +Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%` + + gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) + m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg) + replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo) + if replyTo != "" { + m.SetReplyTo(replyTo) + } + + subs, err := app.db.GetEmailSubscribers(collID, true) + if err != nil { + log.Error("Unable to get email subscribers: %v", err) + return err + } + if len(subs) == 0 { + return nil + } + + if title != "" { + title = string(`

` + p.FormattedDisplayTitle() + `

`) + } + m.AddTag("New post") + + fontFam := "Lora, Palatino, Baskerville, serif" + if p.IsSans() { + fontFam = `"Open Sans", Tahoma, Arial, sans-serif` + } else if p.IsMonospace() { + fontFam = `Hack, consolas, Menlo-Regular, Menlo, Monaco, monospace, monospace` + } + + // TODO: move this to a templated file and LESS-generated stylesheet + fullHTML := ` + + + + +
` + title + `

From ` + p.DisplayCanonicalURL() + `

+ +` + string(p.HTMLContent) + `
+
+ + +` + + // inline CSS + html, err := inliner.Inline(fullHTML) + if err != nil { + log.Error("Unable to inline email HTML: %v", err) + return err + } + + m.SetHtml(html) + + log.Info("[email] Adding %d recipient(s)", len(subs)) + for _, s := range subs { + e := s.FinalEmail(app.keys) + log.Info("[email] Adding %s", e) + err = m.AddRecipientAndVariables(e, map[string]interface{}{ + "id": s.ID, + "to": e, + "token": s.Token, + }) + if err != nil { + log.Error("Unable to add receipient %s: %s", e, err) + } + } + + res, _, err := gun.Send(m) + log.Info("[email] Send result: %s", res) + if err != nil { + log.Error("Unable to send post email: %v", err) + return err + } + + return nil +} + +func sendSubConfirmEmail(app *App, c *Collection, email, subID, token string) error { + if email == "" { + return fmt.Errorf("You must supply an email to verify.") + } + + // Send email + gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) + + plainMsg := "Confirm your subscription to " + c.DisplayTitle() + ` (` + c.CanonicalURL() + `) to start receiving future posts. Simply click the following link (or copy and paste it into your browser): + +` + c.CanonicalURL() + "email/confirm/" + subID + "?t=" + token + ` + +If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.` + m := mailgun.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Email.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email)) + m.AddTag("Email Verification") + + m.SetHtml(` + +
+

Confirm your subscription to ` + c.DisplayTitle() + ` to start receiving future posts:

+

Subscribe to ` + c.DisplayTitle() + `

+

If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.

+
+ +`) + gun.Send(m) + + return nil +} diff --git a/go.mod b/go.mod index 54d38bb..1006487 100644 --- a/go.mod +++ b/go.mod @@ -1,80 +1,90 @@ module github.com/writefreely/writefreely require ( - github.com/dustin/go-humanize v1.0.0 - github.com/fatih/color v1.13.0 - github.com/go-ini/ini v1.66.4 - github.com/go-sql-driver/mysql v1.6.0 + github.com/PuerkitoBio/goquery v1.7.0 // indirect + github.com/aymerick/douceur v0.2.0 + github.com/clbanning/mxj v1.8.4 // indirect + github.com/dustin/go-humanize v1.0.1 + github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect + github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect + github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect + github.com/fatih/color v1.15.0 + github.com/go-ini/ini v1.67.0 + github.com/go-sql-driver/mysql v1.7.1 + github.com/go-test/deep v1.0.1 // indirect + github.com/gobuffalo/envy v1.9.0 // indirect + github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect github.com/gorilla/csrf v1.7.1 github.com/gorilla/feeds v1.1.1 github.com/gorilla/mux v1.8.0 github.com/gorilla/schema v1.2.0 github.com/gorilla/sessions v1.2.1 - github.com/guregu/null v3.5.0+incompatible + github.com/guregu/null v4.0.0+incompatible github.com/hashicorp/go-multierror v1.1.1 github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec + github.com/mailgun/mailgun-go v2.0.0+incompatible github.com/manifoldco/promptui v0.9.0 - github.com/mattn/go-sqlite3 v1.14.16 - github.com/microcosm-cc/bluemonday v1.0.21 + github.com/mattn/go-sqlite3 v1.14.17 + github.com/microcosm-cc/bluemonday v1.0.25 github.com/mitchellh/go-wordwrap v1.0.1 github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d - github.com/stretchr/testify v1.8.1 - github.com/urfave/cli/v2 v2.23.5 + github.com/onsi/ginkgo v1.16.4 // indirect + github.com/onsi/gomega v1.13.0 // indirect + github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect + github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect + github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect + github.com/stretchr/testify v1.8.4 + github.com/urfave/cli/v2 v2.25.7 github.com/writeas/activity v0.1.2 - github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481 + github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835 github.com/writeas/go-strip-markdown/v2 v2.1.1 github.com/writeas/go-webfinger v1.1.0 github.com/writeas/httpsig v1.0.0 github.com/writeas/impart v1.1.1 github.com/writeas/import v0.2.1 - github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 + github.com/writeas/monday v1.3.0 github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 github.com/writeas/slug v1.2.0 - github.com/writeas/web-core v1.4.1 + github.com/writeas/web-core v1.6.0 github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b github.com/writefreely/go-nodeinfo v1.2.0 - golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 - golang.org/x/net v0.0.0-20221002022538-bcab6841153b + golang.org/x/crypto v0.13.0 + golang.org/x/net v0.15.0 ) require ( code.as/core/socks v1.0.0 // indirect - github.com/aymerick/douceur v0.2.0 // indirect + github.com/andybalholm/cascadia v1.1.0 // indirect github.com/beevik/etree v1.1.0 // indirect github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 // indirect github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect - github.com/clbanning/mxj v1.8.4 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe // indirect - github.com/go-test/deep v1.0.1 // indirect github.com/gofrs/uuid v3.3.0+incompatible // indirect github.com/gologme/log v1.2.0 // indirect - github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect github.com/gorilla/css v1.0.0 // indirect github.com/gorilla/securecookie v1.1.1 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/joho/godotenv v1.3.0 // indirect github.com/jtolds/gls v4.2.1+incompatible // indirect - github.com/mattn/go-colorable v0.1.9 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect + github.com/rogpeppe/go-internal v1.3.2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sasha-s/go-deadlock v0.3.1 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect - github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect - github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect - github.com/writeas/go-strip-markdown v2.0.1+incompatible // indirect github.com/writeas/go-writeas/v2 v2.0.2 // indirect github.com/writeas/openssl-go v1.0.0 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect - golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect - golang.org/x/text v0.3.7 // indirect + golang.org/x/sys v0.12.0 // indirect + golang.org/x/text v0.13.0 // indirect gopkg.in/ini.v1 v1.62.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d9e8885..0666a70 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,15 @@ code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs= code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY= +github.com/PuerkitoBio/goquery v1.7.0 h1:O5SP3b9JWqMSVMG69zMfj577zwkSNpxrFf7ybS74eiw= +github.com/PuerkitoBio/goquery v1.7.0/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= +github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= +github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 h1:AFSJaASPGYNbkUa5c8ZybrcW9pP3Cy7+z5dnpcc/qG8= github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ= -github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= @@ -23,25 +26,50 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs= github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= -github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= -github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c h1:8ISkoahWXwZR41ois5lSJBSVw4D0OV19Ht/JSTzvSv0= +github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64= +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A= +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= +github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 h1:7HZCaLC5+BZpmbhCOZJ293Lz68O7PYrF2EzeiFMwCLk= +github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe h1:U71giCx5NjRn4Lb71UuprPHqhjxGv3Jqonb9fgcaJH8= github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= -github.com/go-ini/ini v1.66.4 h1:dKjMqkcbkzfddhIhyglTPgMoJnkvmG+bSLrU9cTHc5M= -github.com/go-ini/ini v1.66.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= +github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg= github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/gobuffalo/envy v1.9.0 h1:eZR0DuEgVLfeIb1zIKt3bT4YovIMf9O9LXQeCZLXpqE= +github.com/gobuffalo/envy v1.9.0/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w= github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84= github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c= github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/csrf v1.7.1 h1:Ir3o2c1/Uzj6FBxMlAUB6SivgVMy1ONXwYgXn+/aHPE= @@ -59,17 +87,21 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= -github.com/guregu/null v3.5.0+incompatible h1:fSdvRTQtmBA4B4YDZXhLtxTIJZYuUxBFTTHS4B9djG4= -github.com/guregu/null v3.5.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM= +github.com/guregu/null v4.0.0+incompatible h1:4zw0ckM7ECd6FNNddc3Fu4aty9nTlpkkzH7dPn4/4Gw= +github.com/guregu/null v4.0.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM= github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE= github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -77,22 +109,36 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec h1:ZXWuspqypleMuJy4bzYEqlMhJnGAYpLrWe5p7W3CdvI= github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g= +github.com/mailgun/mailgun-go v2.0.0+incompatible h1:0FoRHWwMUctnd8KIR3vtZbqdfjpIMxOZgcSa51s8F8o= +github.com/mailgun/mailgun-go v2.0.0+incompatible/go.mod h1:NWTyU+O4aczg/nsGhQnvHL6v2n5Gy6Sv5tNDVvC6FbU= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= -github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U= -github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= -github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/microcosm-cc/bluemonday v1.0.5/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w= -github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg= -github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4= +github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg= +github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE= 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/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.13.0 h1:7lLHu94wT9Ij0o6EWWclhu0aOh32VxhkwEJvzuWPeak= +github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY= github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ= github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -101,6 +147,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= +github.com/rogpeppe/go-internal v1.3.2 h1:XU784Pr0wdahMY2bYcyK6N1KuaRAdLtqD4qd8D18Bfs= +github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0= @@ -112,21 +160,19 @@ github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1 github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/urfave/cli/v2 v2.23.5 h1:xbrU7tAYviSpqeR3X4nEFWUdB/uDZ6DE+HxmRU7Xtyw= -github.com/urfave/cli/v2 v2.23.5/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= +github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY= github.com/writeas/activity v0.1.2/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0= github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481 h1:BiSivIxLQFcKoUorpNN3rNwwFG5bITPnqUSyIccfdh0= github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o= -github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw= -github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE= +github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835 h1:bm/7gYo6y3GxtTa1qyUFyCk29CTnBAKt7z4D2MASYrw= +github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o= github.com/writeas/go-strip-markdown/v2 v2.1.1 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28= github.com/writeas/go-strip-markdown/v2 v2.1.1/go.mod h1:UvvgPJgn1vvN8nWuE5e7v/+qmDu3BSVnKAB6Gl7hFzA= github.com/writeas/go-webfinger v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q= @@ -141,8 +187,8 @@ github.com/writeas/impart v1.1.1 h1:RyA9+CqbdbDuz53k+nXCWUY+NlEkdyw6+nWanxSBl5o= github.com/writeas/impart v1.1.1/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y= github.com/writeas/import v0.2.1 h1:3k+bDNCyqaWdZinyUZtEO4je3mR6fr/nE4ozTh9/9Wg= github.com/writeas/import v0.2.1/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM= -github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 h1:baEp0631C8sT2r/hqwypIw2snCFZa6h7U6TojoLHu/c= -github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219/go.mod h1:NyM35ayknT7lzO6O/1JpfgGyv+0W9Z9q7aE0J8bXxfQ= +github.com/writeas/monday v1.3.0 h1:h51wJ0DULXIDZ1w11zutLL7YCBRO5LznXISSzqVLZeA= +github.com/writeas/monday v1.3.0/go.mod h1:9/CdGLDdIeAvzvf4oeihX++PE/qXUT2+tUlPQKCfRWY= github.com/writeas/openssl-go v1.0.0 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o= github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA= github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ= @@ -150,43 +196,119 @@ github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 h1:PozPZ29CQ/xt github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ= github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g= github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ= -github.com/writeas/web-core v1.4.1 h1:mdDwZepEyQb76j8gNUIPblV7SUIXi4WQ0h3Xl0ZwKT4= -github.com/writeas/web-core v1.4.1/go.mod h1:MTWDZWikeG063S9IrI6ekvu3N2tJEVRpZuU4kAWg1DY= +github.com/writeas/web-core v1.6.0 h1:qOcnbB4RE/kG9g+3ycMRqepj2PljDg2whG/K4A0QB48= +github.com/writeas/web-core v1.6.0/go.mod h1:7+idL4Y4woF7MnUfNX2mvkaQ8nLIJXths2y5iYPtA3k= github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b h1:h3NzB8OZ50NNi5k9yrFeyFszt3LyqyVK4+xUHFYY8B0= github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b/go.mod h1:T2UVVzt+R5KSSZe2xRSytnwc2M9AoDegi7foeIsik+M= 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/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200109152110-61a87790db17/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20221002022538-bcab6841153b h1:6e93nYa3hNqAvLr0pD4PN1fFS+gKzp2zAXqrnTCstqU= -golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 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/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handle.go b/handle.go index e0600bb..1756be3 100644 --- a/handle.go +++ b/handle.go @@ -262,7 +262,7 @@ func apiAuth(app *App, r *http.Request) (*User, error) { return u, nil } -// optionaAPIAuth is used for endpoints that accept authenticated requests via +// optionalAPIAuth is used for endpoints that accept authenticated requests via // Authorization header or cookie, unlike apiAuth. It returns a different err // in the case where no Authorization header is present. func optionalAPIAuth(app *App, r *http.Request) (*User, error) { @@ -818,7 +818,7 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er return } else if err.Status == http.StatusNotFound { w.WriteHeader(err.Status) - if strings.Contains(r.Header.Get("Accept"), "application/activity+json") { + if IsActivityPubRequest(r) { // This is a fediverse request; simply return the header return } diff --git a/jobs.go b/jobs.go new file mode 100644 index 0000000..251b82d --- /dev/null +++ b/jobs.go @@ -0,0 +1,72 @@ +package writefreely + +import ( + "github.com/writeas/web-core/log" + "time" +) + +type PostJob struct { + ID int64 + PostID string + Action string + Delay int64 +} + +func addJob(app *App, p *PublicPost, action string, delay int64) error { + j := &PostJob{ + PostID: p.ID, + Action: action, + Delay: delay, + } + return app.db.InsertJob(j) +} + +func startPublishJobsQueue(app *App) { + t := time.NewTicker(62 * time.Second) + for { + log.Info("[jobs] Done.") + <-t.C + log.Info("[jobs] Fetching email publish jobs...") + jobs, err := app.db.GetJobsToRun("email") + if err != nil { + log.Error("[jobs] %s - Skipping.", err) + continue + } + log.Info("[jobs] Running %d email publish jobs...", len(jobs)) + err = runJobs(app, jobs, true) + if err != nil { + log.Error("[jobs] Failed: %s", err) + } + } +} + +func runJobs(app *App, jobs []*PostJob, reqColl bool) error { + for _, j := range jobs { + p, err := app.db.GetPost(j.PostID, 0) + if err != nil { + log.Info("[job #%d] Unable to get post: %s", j.ID, err) + continue + } + if !p.CollectionID.Valid && reqColl { + log.Info("[job #%d] Post %s not part of a collection", j.ID, p.ID) + app.db.DeleteJob(j.ID) + continue + } + coll, err := app.db.GetCollectionByID(p.CollectionID.Int64) + if err != nil { + log.Info("[job #%d] Unable to get collection: %s", j.ID, err) + continue + } + coll.hostName = app.cfg.App.Host + coll.ForPublic() + p.Collection = &CollectionObj{Collection: *coll} + err = emailPost(app, p, p.Collection.ID) + if err != nil { + log.Error("[job #%d] Failed to email post %s", j.ID, p.ID) + continue + } + log.Info("[job #%d] Success for post %s.", j.ID, p.ID) + app.db.DeleteJob(j.ID) + } + return nil +} diff --git a/keys.go b/keys.go index 98ff13f..b5896f7 100644 --- a/keys.go +++ b/keys.go @@ -13,7 +13,6 @@ package writefreely import ( "github.com/writeas/web-core/log" "github.com/writefreely/writefreely/key" - "io/ioutil" "os" "path/filepath" ) @@ -52,7 +51,7 @@ func initKeyPaths(app *App) { func generateKey(path string) error { // Check if key file exists if _, err := os.Stat(path); err == nil { - log.Info("%s already exists. rm the file if you understand the consquences.", path) + log.Info("%s already exists. rm the file if you understand the consequences.", path) return nil } else if !os.IsNotExist(err) { log.Error("%s", err) @@ -65,7 +64,7 @@ func generateKey(path string) error { log.Error("FAILED. %s. Run writefreely --gen-keys again.", err) return err } - err = ioutil.WriteFile(path, b, 0600) + err = os.WriteFile(path, b, 0600) if err != nil { log.Error("FAILED writing file: %s", err) return err diff --git a/less/admin.less b/less/admin.less index 86dc9ff..673a03b 100644 --- a/less/admin.less +++ b/less/admin.less @@ -60,6 +60,35 @@ nav#admin { background: #ccc; } } + + &.sub { + margin: 1em 0 2em; + a:not(.toggle) { + border: 0; + border-bottom: 2px transparent solid; + .rounded(0); + padding: 0.5em; + margin-left: 0.5em; + margin-right: 0.5em; + + &:hover { + color: @primary; + background: transparent; + } + &.selected { + color: @primary; + background: transparent; + border-bottom-color: @primary; + } + &+a { + margin-left: 1em; + } + } + a.toggle { + margin-top: -0.5em; + float: right; + } + } } .admin-actions { diff --git a/less/core.less b/less/core.less index d132414..7114335 100644 --- a/less/core.less +++ b/less/core.less @@ -210,6 +210,10 @@ body { pre { line-height: 1.5; } + .flash { + text-align: center; + margin-bottom: 4em; + } } &#subpage { #wrapper { @@ -830,6 +834,9 @@ input { margin: 0 auto 3em; font-size: 1.2em; + &.toosmall { + max-width: 25em; + } &.tight { max-width: 30em; } @@ -1600,6 +1607,18 @@ pre.code-block { overflow-x: auto; } +#emailsub { + text-align: center; +} +p#emailsub { + display: inline-block !important; + width: 100%; + font-style: italic; +} +#subscribe-btn { + margin-left: 0.5em; +} + #org-nav { font-family: @sylexiadFont; font-size: 1.1em; diff --git a/less/fonts.less b/less/fonts.less index 999ee7b..7568650 100644 --- a/less/fonts.less +++ b/less/fonts.less @@ -3,7 +3,6 @@ font-family: 'Open Sans'; font-style: normal; font-weight: 400; - font-display: optional; src: url('/fonts/open-sans-v13-latin-regular.eot'); /* IE9 Compat Modes */ src: local('Open Sans'), local('OpenSans'), url('/fonts/open-sans-v13-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ @@ -17,7 +16,6 @@ font-family: 'Open Sans'; font-style: normal; font-weight: 700; - font-display: optional; src: url('/fonts/open-sans-v13-latin-700.eot'); /* IE9 Compat Modes */ src: local('Open Sans Bold'), local('OpenSans-Bold'), url('/fonts/open-sans-v13-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ @@ -31,7 +29,6 @@ font-family: 'Lora'; font-style: normal; font-weight: 400; - font-display: optional; src: url('/fonts/Lora-Regular.eot'); /* IE9 Compat Modes */ src: local('Lora'), local('Lora-Regular'), url('/fonts/Lora-Regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ @@ -44,7 +41,6 @@ font-family: 'Lora'; font-style: normal; font-weight: 700; - font-display: optional; src: url('/fonts/Lora-Bold.eot'); /* IE9 Compat Modes */ src: local('Lora Bold'), local('Lora-Bold'), url('/fonts/Lora-Bold.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ @@ -56,7 +52,6 @@ font-family: 'Lora'; font-style: italic; font-weight: 400; - font-display: optional; src: url('/fonts/Lora-Italic.eot'); /* IE9 Compat Modes */ src: local('Lora Italic'), local('Lora-Italic'), url('/fonts/Lora-Italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ diff --git a/migrations/drivers.go b/migrations/drivers.go index 967ee97..5c6958a 100644 --- a/migrations/drivers.go +++ b/migrations/drivers.go @@ -36,6 +36,13 @@ func (db *datastore) typeSmallInt() string { return "SMALLINT" } +func (db *datastore) typeTinyInt() string { + if db.driverName == driverSQLite { + return "INTEGER" + } + return "TINYINT" +} + func (db *datastore) typeText() string { return "TEXT" } @@ -54,6 +61,13 @@ func (db *datastore) typeVarChar(l int) string { return fmt.Sprintf("VARCHAR(%d)", l) } +func (db *datastore) typeVarBinary(l int) string { + if db.driverName == driverSQLite { + return "BLOB" + } + return fmt.Sprintf("VARBINARY(%d)", l) +} + func (db *datastore) typeBool() string { if db.driverName == driverSQLite { return "INTEGER" @@ -65,6 +79,15 @@ func (db *datastore) typeDateTime() string { return "DATETIME" } +func (db *datastore) typeIntPrimaryKey() string { + if db.driverName == driverSQLite { + // From docs: "In SQLite, a column with type INTEGER PRIMARY KEY is an alias for the ROWID (except in WITHOUT + // ROWID tables) which is always a 64-bit signed integer." + return "INTEGER PRIMARY KEY" + } + return "INT AUTO_INCREMENT PRIMARY KEY" +} + func (db *datastore) collateMultiByte() string { if db.driverName == driverSQLite { return "" diff --git a/migrations/migrations.go b/migrations/migrations.go index 310590b..fc638ee 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -65,7 +65,12 @@ var migrations = []Migration{ New("support oauth attach", oauthAttach), // V6 -> V7 New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0) New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9 - New("support post signatures", supportPostSignatures), // V9 -> V10 + New("support post signatures", supportPostSignatures), // V9 -> V10 (v0.13.0) + New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11 + New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 (v0.14.0) + New("support newsletters", supportLetters), // V12 -> V13 + New("support password resetting", supportPassReset), // V13 -> V14 + New("speed up blog post retrieval", addPostRetrievalIndex), // V14 -> V15 } // CurrentVer returns the current migration version the application is on diff --git a/migrations/v11.go b/migrations/v11.go new file mode 100644 index 0000000..412c648 --- /dev/null +++ b/migrations/v11.go @@ -0,0 +1,38 @@ +/* + * Copyright © 2020 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +/** + * Widen `oauth_users.access_token`, necessary only for mysql + */ +func widenOauthAcceesToken(db *datastore) error { + if db.driverName == driverMySQL { + t, err := db.Begin() + if err != nil { + t.Rollback() + return err + } + + _, err = t.Exec(`ALTER TABLE oauth_users MODIFY COLUMN access_token ` + db.typeText() + db.collateMultiByte() + ` NULL`) + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + } + + return nil +} diff --git a/migrations/v12.go b/migrations/v12.go new file mode 100644 index 0000000..bed93fd --- /dev/null +++ b/migrations/v12.go @@ -0,0 +1,33 @@ +/* + * Copyright © 2023 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func fediverseVerifyProfile(db *datastore) error { + t, err := db.Begin() + if err != nil { + t.Rollback() + return err + } + + _, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN url ` + db.typeVarChar(255) + ` NULL` + db.after("shared_inbox")) + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + + return nil +} diff --git a/migrations/v13.go b/migrations/v13.go new file mode 100644 index 0000000..908ceac --- /dev/null +++ b/migrations/v13.go @@ -0,0 +1,58 @@ +/* + * Copyright © 2021 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func supportLetters(db *datastore) error { + t, err := db.Begin() + if err != nil { + t.Rollback() + return err + } + + _, err = t.Exec(`CREATE TABLE publishjobs ( + id ` + db.typeIntPrimaryKey() + `, + post_id ` + db.typeVarChar(16) + ` not null, + action ` + db.typeVarChar(16) + ` not null, + delay ` + db.typeTinyInt() + ` not null +)`) + if err != nil { + t.Rollback() + return err + } + + _, err = t.Exec(`CREATE TABLE emailsubscribers ( + id ` + db.typeChar(8) + ` not null, + collection_id ` + db.typeInt() + ` not null, + user_id ` + db.typeInt() + ` null, + email ` + db.typeVarChar(255) + ` null, + subscribed ` + db.typeDateTime() + ` not null, + token ` + db.typeChar(16) + ` not null, + confirmed ` + db.typeBool() + ` default 0 not null, + allow_export ` + db.typeBool() + ` default 0 not null, + constraint eu_coll_email + unique (collection_id, email), + constraint eu_coll_user + unique (collection_id, user_id), + PRIMARY KEY (id) +)`) + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + + return nil +} diff --git a/migrations/v14.go b/migrations/v14.go new file mode 100644 index 0000000..2883001 --- /dev/null +++ b/migrations/v14.go @@ -0,0 +1,37 @@ +/* + * Copyright © 2023 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func supportPassReset(db *datastore) error { + t, err := db.Begin() + if err != nil { + t.Rollback() + return err + } + + _, err = t.Exec(`CREATE TABLE password_resets ( + user_id ` + db.typeInt() + ` not null, + token ` + db.typeChar(32) + ` not null primary key, + used ` + db.typeBool() + ` default 0 not null, + created ` + db.typeDateTime() + ` not null +)`) + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + return nil +} diff --git a/migrations/v15.go b/migrations/v15.go new file mode 100644 index 0000000..0875c5a --- /dev/null +++ b/migrations/v15.go @@ -0,0 +1,33 @@ +/* + * Copyright © 2023 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func addPostRetrievalIndex(db *datastore) error { + t, err := db.Begin() + if err != nil { + t.Rollback() + return err + } + + _, err = t.Exec("CREATE INDEX posts_get_collection_index ON posts (`collection_id`, `pinned_position`, `created`)") + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + + return nil +} diff --git a/monetization.go b/monetization.go index 3bb4fcf..4d6b42b 100644 --- a/monetization.go +++ b/monetization.go @@ -16,7 +16,7 @@ import ( "github.com/gorilla/mux" "github.com/writeas/impart" "github.com/writeas/web-core/log" - "io/ioutil" + "io" "net/http" "net/url" "os" @@ -144,7 +144,7 @@ func verifyReceipt(receipt, id string) error { defer resp.Body.Close() } - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { log.Error("Unable to read %s response body: %s", receiptsHost, err) return err diff --git a/nodeinfo.go b/nodeinfo.go index 4902c42..6ce3572 100644 --- a/nodeinfo.go +++ b/nodeinfo.go @@ -94,14 +94,20 @@ INNER JOIN collections c ON collection_id = c.id WHERE collection_id IS NOT NULL AND updated > DATE_SUB(NOW(), INTERVAL 6 MONTH)) co`).Scan(&activeHalfYear) + if err != nil { + log.Error("Failed getting 6-month active user stats: %s", err) + } err = r.db.QueryRow(`SELECT COUNT(*) FROM ( SELECT DISTINCT collection_id FROM posts -INNER JOIN FROM collections c +INNER JOIN collections c ON collection_id = c.id WHERE collection_id IS NOT NULL AND updated > DATE_SUB(NOW(), INTERVAL 1 MONTH)) co`).Scan(&activeMonth) + if err != nil { + log.Error("Failed getting 1-month active user stats: %s", err) + } } return nodeinfo.Usage{ diff --git a/oauth.go b/oauth.go index ee08740..06a2d20 100644 --- a/oauth.go +++ b/oauth.go @@ -15,7 +15,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" "net/url" "strings" @@ -450,7 +449,7 @@ func (r *callbackProxyClient) register(ctx context.Context, state string) error func limitedJsonUnmarshal(body io.ReadCloser, n int, thing interface{}) error { lr := io.LimitReader(body, int64(n+1)) - data, err := ioutil.ReadAll(lr) + data, err := io.ReadAll(lr) if err != nil { return err } diff --git a/ossl_legacy.cnf b/ossl_legacy.cnf new file mode 100644 index 0000000..90c36f2 --- /dev/null +++ b/ossl_legacy.cnf @@ -0,0 +1,10 @@ + +[provider_sect] +default = default_sect +legacy = legacy_sect + +[default_sect] +activate = 1 + +[legacy_sect] +activate = 1 diff --git a/pages.go b/pages.go index 8b3a987..6ebc3dc 100644 --- a/pages.go +++ b/pages.go @@ -40,6 +40,28 @@ func defaultAboutTitle(cfg *config.Config) sql.NullString { return sql.NullString{String: "About " + cfg.App.SiteName, Valid: true} } +func getContactPage(app *App) (*instanceContent, error) { + c, err := app.db.GetDynamicContent("contact") + if err != nil { + return nil, err + } + if c == nil { + c = &instanceContent{ + ID: "contact", + Type: "page", + Content: defaultContactPage(app), + } + } + if !c.Title.Valid { + c.Title = defaultContactTitle() + } + return c, nil +} + +func defaultContactTitle() sql.NullString { + return sql.NullString{String: "Contact Us", Valid: true} +} + func getPrivacyPage(app *App) (*instanceContent, error) { c, err := app.db.GetDynamicContent("privacy") if err != nil { @@ -70,12 +92,24 @@ func defaultAboutPage(cfg *config.Config) string { return `_` + cfg.App.SiteName + `_ is a place for you to write and publish, powered by [WriteFreely](https://writefreely.org).` } +func defaultContactPage(app *App) string { + c, err := app.db.GetCollectionByID(1) + if err != nil { + return "" + } + return `_` + app.cfg.App.SiteName + `_ is administered by: [**` + c.Alias + `**](/` + c.Alias + `/). + +Contact them at this email address: _EMAIL GOES HERE_. + +You can also reach them here...` +} + func defaultPrivacyPolicy(cfg *config.Config) string { return `[WriteFreely](https://writefreely.org), the software that powers this site, is built to enforce your right to privacy by default. It retains as little data about you as possible, not even requiring an email address to sign up. However, if you _do_ give us your email address, it is stored encrypted in our database. We salt and hash your account's password. -We store log files, or data about what happens on our servers. We also use cookies to keep you logged in to your account. +We store log files, or data about what happens on our servers. We also use cookies to keep you logged into your account. Beyond this, it's important that you trust whoever runs **` + cfg.App.SiteName + `**. Software can only do so much to protect you -- your level of privacy protections will ultimately fall on the humans that run this particular service.` } diff --git a/pages/404-general.tmpl b/pages/404-general.tmpl index dfc4653..07f2549 100644 --- a/pages/404-general.tmpl +++ b/pages/404-general.tmpl @@ -1,7 +1,6 @@ {{define "head"}}Page not found — {{.SiteName}}{{end}} {{define "content"}}
-

This page is missing.

-

Are you sure it was ever here?

+

Page not found.

{{end}} diff --git a/pages/500.tmpl b/pages/500.tmpl index e148fb5..4240348 100644 --- a/pages/500.tmpl +++ b/pages/500.tmpl @@ -2,9 +2,7 @@ {{define "content"}}

Server error 😵

-

Please contact the human authors of this software and remind them of their many shortcomings.

-

Be gentle, though. They are fragile mortal beings.

-

Also, unlike the AI that will soon replace them, you will need to include an error log from the server in your report. (Utterly primitive, we know.)

+

There seems to be an issue with this server. Please contact the admin and let them know they'll need to fix it.

– {{.SiteName}} 🤖

{{end}} diff --git a/pages/contact.tmpl b/pages/contact.tmpl new file mode 100644 index 0000000..8dff9ae --- /dev/null +++ b/pages/contact.tmpl @@ -0,0 +1,8 @@ +{{define "head"}}{{.ContentTitle}} — {{.SiteName}} + +{{end}} +{{define "content"}}
+

{{.ContentTitle}}

+ {{.Content}} +
+{{end}} diff --git a/pages/login.tmpl b/pages/login.tmpl index f0a54eb..c908613 100644 --- a/pages/login.tmpl +++ b/pages/login.tmpl @@ -1,13 +1,19 @@ {{define "head"}}Log in — {{.SiteName}} - - + + {{end}} {{define "content"}}
-

Log in to {{.SiteName}}

+

Log into {{.SiteName}}

{{if .Flashes}}
    {{range .Flashes}}
  • {{.}}
  • {{end}} @@ -19,6 +25,7 @@ input{margin-bottom:0.5em;}


    + {{if .EmailEnabled}}

    Forgot password?

    {{end}} {{if .To}}{{end}}
    diff --git a/pages/reset.tmpl b/pages/reset.tmpl new file mode 100644 index 0000000..bc18377 --- /dev/null +++ b/pages/reset.tmpl @@ -0,0 +1,58 @@ +{{define "head"}}Reset password — {{.SiteName}} + +{{end}} +{{define "content"}} +
    +

    Reset your password

    + +{{ if .DisablePasswordAuth }} +
    +

    Password login is disabled on this server, so it's not possible to reset your password.

    +
    +{{ else if not .EmailEnabled }} +
    +

    Email is not configured on this server! Please contact your admin to reset your password.

    +
    +{{ else }} + {{if .Flashes}}
      + {{range .Flashes}}
    • {{.}}
    • {{end}} +
    {{end}} + + {{if .IsResetting}} +
    + + + + {{ .CSRFField }} +
    + {{else if not .IsSent}} +
    + + {{ .CSRFField }} + +
    + {{end}} + + +{{ end }} +{{end}} diff --git a/postrender.go b/postrender.go index 5be8d0c..2156649 100644 --- a/postrender.go +++ b/postrender.go @@ -120,7 +120,7 @@ func (p *PublicPost) augmentReadingDestination() { } func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string { - return applyMarkdownSpecial(data, false, baseURL, cfg) + return applyMarkdownSpecial(data, baseURL, cfg, cfg.App.SingleUser) } func disableYoutubeAutoplay(outHTML string) string { @@ -142,7 +142,7 @@ func disableYoutubeAutoplay(outHTML string) string { return outHTML } -func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *config.Config) string { +func applyMarkdownSpecial(data []byte, baseURL string, cfg *config.Config, skipNoFollow bool) string { mdExtensions := 0 | blackfriday.EXTENSION_TABLES | blackfriday.EXTENSION_FENCED_CODE | @@ -270,6 +270,7 @@ func getSanitizationPolicy() *bluemonday.Policy { policy.AllowAttrs("target").OnElements("a") policy.AllowAttrs("title").OnElements("abbr") policy.AllowAttrs("style", "class", "id").Globally() + policy.AllowAttrs("alt").OnElements("img") policy.AllowElements("header", "footer") policy.AllowURLSchemes("http", "https", "mailto", "xmpp") return policy @@ -284,12 +285,13 @@ func sanitizePost(content string) string { // choosing what to generate. In case a post has a title, this function will // fail, and logic should instead be implemented to skip this when there's no // title, like so: -// var desc string -// if title == "" { -// desc = postDescription(content, title, friendlyId) -// } else { -// desc = shortPostDescription(content) -// } +// +// var desc string +// if title == "" { +// desc = postDescription(content, title, friendlyId) +// } else { +// desc = shortPostDescription(content) +// } func postDescription(content, title, friendlyId string) string { maxLen := 140 diff --git a/posts.go b/posts.go index e95532e..f98b1fb 100644 --- a/posts.go +++ b/posts.go @@ -14,6 +14,7 @@ import ( "database/sql" "encoding/json" "fmt" + "github.com/writefreely/writefreely/spam" "html/template" "net/http" "net/url" @@ -139,6 +140,7 @@ type ( IsPinned bool IsCustomDomain bool Monetization string + Verification string PinnedPosts *[]PublicPost IsFound bool IsAdmin bool @@ -354,7 +356,7 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { return impart.HTTPError{http.StatusFound, fmt.Sprintf("/%s%s", fixedID, ext)} } - err := app.db.QueryRow(fmt.Sprintf("SELECT owner_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?"), friendlyID).Scan(&ownerID, &title, &content, &font, &views, &language, &rtl) + err := app.db.QueryRow("SELECT owner_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?", friendlyID).Scan(&ownerID, &title, &content, &font, &views, &language, &rtl) switch { case err == sql.ErrNoRows: found = false @@ -516,9 +518,9 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { // newPost creates a new post with or without an owning Collection. // // Endpoints: -// /posts -// /posts?collection={alias} -// ? /collections/{alias}/posts +// - /posts +// - /posts?collection={alias} +// - ? /collections/{alias}/posts func newPost(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) vars := mux.Vars(r) @@ -651,8 +653,17 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error { // Write success now response := impart.WriteSuccess(w, newPost, http.StatusCreated) - if newPost.Collection != nil && !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) { - go federatePost(app, newPost, newPost.Collection.ID, false) + if newPost.Collection != nil { + if !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) { + go federatePost(app, newPost, newPost.Collection.ID, false) + } + if app.cfg.Email.Enabled() && newPost.Collection.EmailSubsEnabled() { + go app.db.InsertJob(&PostJob{ + PostID: newPost.ID, + Action: "email", + Delay: emailSendDelay, + }) + } } return response @@ -952,16 +963,23 @@ func addPost(app *App, w http.ResponseWriter, r *http.Request) error { return err } - if !app.cfg.App.Private && app.cfg.App.Federation { - for _, pRes := range *res { - if pRes.Code != http.StatusOK { - continue - } + for _, pRes := range *res { + if pRes.Code != http.StatusOK { + continue + } + if !app.cfg.App.Private && app.cfg.App.Federation { if !pRes.Post.Created.After(time.Now()) { pRes.Post.Collection.hostName = app.cfg.App.Host go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false) } } + if app.cfg.Email.Enabled() && pRes.Post.Collection.EmailSubsEnabled() { + go app.db.InsertJob(&PostJob{ + PostID: pRes.Post.ID, + Action: "email", + Delay: emailSendDelay, + }) + } } return impart.WriteSuccess(w, res, http.StatusOK) } @@ -1067,7 +1085,7 @@ func pinPost(app *App, w http.ResponseWriter, r *http.Request) error { ppr := PinPostResult{ID: p.ID} if err != nil { ppr.Code = http.StatusInternalServerError - // TODO: set error messsage + // TODO: set error message } else { ppr.Code = http.StatusOK } @@ -1119,8 +1137,7 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error { p.extractData() - accept := r.Header.Get("Accept") - if strings.Contains(accept, "application/activity+json") { + if IsActivityPubRequest(r) { if coll == nil { // This is a draft post; 404 for now // TODO: return ActivityObject @@ -1164,6 +1181,15 @@ func (p *PublicPost) CanonicalURL(hostName string) string { return p.Collection.CanonicalURL() + p.Slug.String } +func (pp *PublicPost) DisplayCanonicalURL() string { + us := pp.CanonicalURL(pp.Collection.hostName) + u, err := url.Parse(us) + if err != nil { + return us + } + return u.Hostname() + u.Path +} + func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object { cfg := app.cfg var o *activitystreams.Object @@ -1520,7 +1546,7 @@ Are you sure it was ever here?`, fmt.Fprintf(w, "# %s\n\n", p.Title.String) } fmt.Fprint(w, p.Content) - } else if strings.Contains(r.Header.Get("Accept"), "application/activity+json") { + } else if IsActivityPubRequest(r) { if !postFound { return ErrCollectionPageNotFound } @@ -1532,6 +1558,15 @@ Are you sure it was ever here?`, } else { p.extractData() p.Content = strings.Replace(p.Content, "", "", 1) + if app.cfg.Email.Enabled() && c.EmailSubsEnabled() { + // TODO: indicate plan is inactive or subs disabled when OWNER is viewing their own post. + if u != nil && u.IsEmailSubscriber(app, c.ID) { + p.Content = strings.Replace(p.Content, "", `

    You're subscribed to email updates. Unsubscribe.

    `, -1) + } else { + p.Content = strings.Replace(p.Content, "", `
    `, -1) + } + } + p.Content = strings.Replace(p.Content, "<!--emailsub-->", "", 1) // TODO: move this to function p.formatContent(app.cfg, cr.isCollOwner, true) tp := CollectionPostPage{ @@ -1547,7 +1582,8 @@ 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") + tp.Monetization = coll.Monetization + tp.Verification = coll.Verification if !postFound { w.WriteHeader(http.StatusNotFound) @@ -1595,6 +1631,14 @@ func (p *Post) extractData() { p.extractImages() } +func (p *Post) IsSans() bool { + return p.Font == "sans" +} + +func (p *Post) IsMonospace() bool { + return p.Font == "mono" +} + func (rp *RawPost) UserFacingCreated() string { return rp.Created.Format(postMetaDateFormat) } diff --git a/request.go b/request.go index 0bb6e92..9ff9983 100644 --- a/request.go +++ b/request.go @@ -13,6 +13,7 @@ package writefreely import ( "mime" "net/http" + "strings" ) func IsJSON(r *http.Request) bool { @@ -20,3 +21,9 @@ func IsJSON(r *http.Request) bool { accept := r.Header.Get("Accept") return ct == "application/json" || accept == "application/json" } + +func IsActivityPubRequest(r *http.Request) bool { + accept := r.Header.Get("Accept") + return strings.Contains(accept, "application/activity+json") || + accept == "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" +} diff --git a/routes.go b/routes.go index 00b6bd0..2e4e8c2 100644 --- a/routes.go +++ b/routes.go @@ -82,7 +82,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { configureGenericOauth(handler, write, apper.App()) configureGiteaOauth(handler, write, apper.App()) - // Set up dyamic page handlers + // Set up dynamic page handlers // Handle auth auth := write.PathPrefix("/api/auth/").Subrouter() if apper.App().cfg.App.OpenRegistration { @@ -99,6 +99,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET") me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET") me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET") + me.HandleFunc("/c/{collection}/subscribers", handler.User(handleViewSubscribers)).Methods("GET") me.Path("/delete").Handler(csrf.Protect(apper.App().keys.CSRFKey)(handler.User(handleUserDelete))).Methods("POST") me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET") me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET") @@ -147,6 +148,9 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST") apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST") apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST") + apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleCreateEmailSubscription)).Methods("POST") + apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleDeleteEmailSubscription)).Methods("DELETE") + apiColls.HandleFunc("/{collection}/email/unsubscribe", handler.All(handleDeleteEmailSubscription)).Methods("GET") apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST") apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET") apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET") @@ -180,6 +184,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET") // Handle special pages first + write.Path("/reset").Handler(csrf.Protect(apper.App().keys.CSRFKey)(handler.Web(viewResetPassword, UserLevelNoneRequired))) write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired)) write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET") @@ -216,10 +221,15 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { func RouteCollections(handler *Handler, r *mux.Router) { r.HandleFunc("/logout", handler.Web(handleLogOutCollection, UserLevelOptional)) r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader)) + r.HandleFunc("/lang:{lang:[a-z]{2}}", handler.Web(handleViewCollectionLang, UserLevelOptional)) + r.HandleFunc("/lang:{lang:[a-z]{2}}/page/{page:[0-9]+}", handler.Web(handleViewCollectionLang, UserLevelOptional)) r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader)) + r.HandleFunc("/tag:{tag}/page/{page:[0-9]+}", handler.Web(handleViewCollectionTag, UserLevelReader)) r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader)) r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap)) r.HandleFunc("/feed/", handler.AllReader(ViewFeed)) + r.HandleFunc("/email/confirm/{subscriber}", handler.All(handleConfirmEmailSubscription)).Methods("GET") + r.HandleFunc("/email/unsubscribe/{subscriber}", handler.All(handleDeleteEmailSubscription)).Methods("GET") r.HandleFunc("/{slug}", handler.CollectionPostOrStatic) r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser)) r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser)) diff --git a/scripts/upgrade-server.sh b/scripts/upgrade-server.sh index b129943..f010c94 100755 --- a/scripts/upgrade-server.sh +++ b/scripts/upgrade-server.sh @@ -2,7 +2,7 @@ ############################################################################### ## writefreely update script ## ## ## -## WARNING: running this script will overwrite any modifed assets or ## +## WARNING: running this script will overwrite any modified assets or ## ## template files. If you have any custom changes to these files you ## ## should back them up FIRST. ## ## ## diff --git a/session.go b/session.go index 91c42fe..03d3963 100644 --- a/session.go +++ b/session.go @@ -21,6 +21,10 @@ import ( const ( day = 86400 sessionLength = 180 * day + + userEmailCookieName = "ue" + userEmailCookieVal = "email" + cookieName = "wfu" cookieUserVal = "u" diff --git a/spam/email.go b/spam/email.go new file mode 100644 index 0000000..de017ab --- /dev/null +++ b/spam/email.go @@ -0,0 +1,43 @@ +/* + * Copyright © 2020-2021 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package spam + +import ( + "github.com/writeas/web-core/id" + "strings" +) + +var honeypotField string + +func HoneypotFieldName() string { + if honeypotField == "" { + honeypotField = id.Generate62RandomString(39) + } + return honeypotField +} + +// CleanEmail takes an email address and strips it down to a unique address that can be blocked. +func CleanEmail(email string) string { + emailParts := strings.Split(strings.ToLower(email), "@") + if len(emailParts) < 2 { + return "" + } + u := emailParts[0] + d := emailParts[1] + // Ignore anything after '+' + plusIdx := strings.IndexRune(u, '+') + if plusIdx > -1 { + u = u[:plusIdx] + } + // Strip dots in email address + u = strings.ReplaceAll(u, ".", "") + return u + "@" + d +} diff --git a/spam/ip.go b/spam/ip.go new file mode 100644 index 0000000..89e317f --- /dev/null +++ b/spam/ip.go @@ -0,0 +1,25 @@ +/* + * Copyright © 2023 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package spam + +import ( + "net/http" + "strings" +) + +func GetIP(r *http.Request) string { + h := r.Header.Get("X-Forwarded-For") + if h == "" { + return "" + } + ips := strings.Split(h, ",") + return strings.TrimSpace(ips[0]) +} diff --git a/static/js/README.md b/static/js/README.md index 7e387db..8497b66 100644 --- a/static/js/README.md +++ b/static/js/README.md @@ -1,6 +1,6 @@ # static/js -This directory is for Javascript. +This directory is for JavaScript. ## Updating libraries diff --git a/templates.go b/templates.go index ecd8750..3bb7d13 100644 --- a/templates.go +++ b/templates.go @@ -14,9 +14,8 @@ import ( "errors" "html/template" "io" - "io/ioutil" - "net/http" "os" + "net/http" "path/filepath" "strings" @@ -120,7 +119,7 @@ func initUserPage(parentDir, path, key string) { // InitTemplates loads all template files from the configured parent dir. func InitTemplates(cfg *config.Config) error { log.Info("Loading templates...") - tmplFiles, err := ioutil.ReadDir(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir)) + tmplFiles, err := os.ReadDir(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir)) if err != nil { return err } @@ -135,7 +134,10 @@ func InitTemplates(cfg *config.Config) error { log.Info("Loading pages...") // Initialize all static pages that use the base template - filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, pagesDir), func(path string, i os.FileInfo, err error) error { + err = filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, pagesDir), func(path string, i os.FileInfo, err error) error { + if err != nil { + return err + } if !i.IsDir() && !strings.HasPrefix(i.Name(), ".") { key := i.Name() initPage(cfg.Server.PagesParentDir, path, key) @@ -143,10 +145,16 @@ func InitTemplates(cfg *config.Config) error { return nil }) + if err != nil { + return err + } log.Info("Loading user pages...") // Initialize all user pages that use base templates - filepath.Walk(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir, "user"), func(path string, f os.FileInfo, err error) error { + err = filepath.Walk(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir, "user"), func(path string, f os.FileInfo, err error) error { + if err != nil { + return err + } if !f.IsDir() && !strings.HasPrefix(f.Name(), ".") { corePath := path if cfg.Server.TemplatesParentDir != "" { @@ -162,6 +170,9 @@ func InitTemplates(cfg *config.Config) error { return nil }) + if err != nil { + return err + } return nil } diff --git a/templates/bare.tmpl b/templates/bare.tmpl index a5f9910..cd0fa8c 100644 --- a/templates/bare.tmpl +++ b/templates/bare.tmpl @@ -28,7 +28,7 @@
- +
{{if .Editing}}{{end}}
diff --git a/templates/chorus-collection.tmpl b/templates/chorus-collection.tmpl index 2bc165d..0fb5eaf 100644 --- a/templates/chorus-collection.tmpl +++ b/templates/chorus-collection.tmpl @@ -9,8 +9,8 @@ {{if .CustomCSS}}{{end}} - {{if gt .CurrentPage 1}}{{end}} - {{if lt .CurrentPage .TotalPages}}{{end}} + {{if gt .CurrentPage 1}}{{end}} + {{if lt .CurrentPage .TotalPages}}{{end}} {{if not .IsPrivate}}{{end}} @@ -92,11 +92,11 @@ body#collection header nav.tabs a:first-child { {{if gt .TotalPages 1}}{{end}} diff --git a/templates/classic.tmpl b/templates/classic.tmpl index c253fe3..0a644bc 100644 --- a/templates/classic.tmpl +++ b/templates/classic.tmpl @@ -63,7 +63,7 @@
- +
{{if .Editing}}{{end}} diff --git a/templates/collection-tags.tmpl b/templates/collection-tags.tmpl index 6a989a7..c8e8a12 100644 --- a/templates/collection-tags.tmpl +++ b/templates/collection-tags.tmpl @@ -61,6 +61,17 @@ {{if .Posts}}
{{else}}
{{end}}

{{.Tag}}

{{template "posts" .}} + + {{if gt .TotalPages 1}}{{end}} + {{if .Posts}}
{{else}}
{{end}} {{ if .Collection.ShowFooterBranding }} diff --git a/templates/collection.tmpl b/templates/collection.tmpl index d0f6db4..be808f8 100644 --- a/templates/collection.tmpl +++ b/templates/collection.tmpl @@ -9,8 +9,8 @@ {{if .CustomCSS}}{{end}} - {{if gt .CurrentPage 1}}{{end}} - {{if lt .CurrentPage .TotalPages}}{{end}} + {{if gt .CurrentPage 1}}{{end}} + {{if lt .CurrentPage .TotalPages}}{{end}} {{if not .IsPrivate}}{{end}} @@ -54,6 +54,7 @@ {{if .SimpleNav}}
  • New Post
  • {{end}}
  • Customize
  • Stats
  • +
  • Subscribers

  • {{if not .SingleUser}}
  • View Blogs
  • {{end}}
  • View Drafts
  • @@ -103,18 +104,26 @@ {{end}} + {{if .Flash}} +
    +

    {{.Flash}}

    +
    + {{end}} + {{template "posts" .}} {{if gt .TotalPages 1}}{{end}} + {{if not .IsWelcome}}{{template "emailsubscribe" .}}{{end}} + {{if .Posts}}{{else}}{{end}} {{if .ShowFooterBranding }} diff --git a/templates/include/post-render.tmpl b/templates/include/post-render.tmpl index 5b84845..4e98d80 100644 --- a/templates/include/post-render.tmpl +++ b/templates/include/post-render.tmpl @@ -1,8 +1,11 @@ - + {{define "collection-meta"}} {{if .Monetization -}} {{- end}} + {{if .Verification -}} + + {{- end}} {{end}} {{define "highlighting"}} @@ -76,7 +79,7 @@ jss.push(lurl); } } - // Load files in order, higlight on last load + // Load files in order, highlight on last load loadLanguages(jss, () => {highlight(lb)}); } }); @@ -102,3 +105,28 @@ {{end}} + +{{define "emailsubscribe"}} + {{if .EmailSubsEnabled}} +
    + {{if .IsSubscriber}} +

    You're subscribed to email updates. Unsubscribe.

    + {{else}} +
    + +

    Enter your email to subscribe to updates.

    + + +
    + + {{end}} +
    + {{end}} +{{end}} \ No newline at end of file diff --git a/templates/pad.tmpl b/templates/pad.tmpl index b87308e..750b670 100644 --- a/templates/pad.tmpl +++ b/templates/pad.tmpl @@ -58,7 +58,7 @@ - +
    {{if .Editing}}{{end}} diff --git a/templates/user/admin/users.tmpl b/templates/user/admin/users.tmpl index f6b218c..935d445 100644 --- a/templates/user/admin/users.tmpl +++ b/templates/user/admin/users.tmpl @@ -4,7 +4,7 @@
    {{template "admin-header" .}} - + {{if .Flashes}}

    {{range .Flashes}}{{.}}{{end}} diff --git a/templates/user/admin/view-page.tmpl b/templates/user/admin/view-page.tmpl index 161e40b..dfcf4cd 100644 --- a/templates/user/admin/view-page.tmpl +++ b/templates/user/admin/view-page.tmpl @@ -29,6 +29,8 @@ input[type=text] { {{if eq .Content.ID "about"}}

    Describe what your instance is about.

    + {{else if eq .Content.ID "contact"}} +

    Tell your users and outside visitors how to contact you.

    {{else if eq .Content.ID "privacy"}}

    Outline your privacy policy.

    {{else if eq .Content.ID "reader"}} diff --git a/templates/user/admin/view-user.tmpl b/templates/user/admin/view-user.tmpl index dac88bf..4a06c03 100644 --- a/templates/user/admin/view-user.tmpl +++ b/templates/user/admin/view-user.tmpl @@ -45,7 +45,7 @@ input.copy-text { {{if .NewPassword}}

    This user's password has been reset to:

    -

    They can use this new password to log in to their account. This will only be shown once, so be sure to copy it and send it to them now.

    +

    They can use this new password to log into their account. This will only be shown once, so be sure to copy it and send it to them now.

    {{if .ClearEmail}}

    Their email address is: {{.ClearEmail}}

    {{end}}
    {{end}} diff --git a/templates/user/collection.tmpl b/templates/user/collection.tmpl index 9f16faa..ef3982b 100644 --- a/templates/user/collection.tmpl +++ b/templates/user/collection.tmpl @@ -90,6 +90,44 @@ textarea.section.norm {
    +
    +

    Updates

    +
    +

    Keep readers updated with your latest posts wherever they are.

    +
      +
    • + +

      Readers can subscribe to your blog's RSS feed with their favorite RSS reader.

      +
    • + {{if .EmailCfg.Enabled}} +
    • + +

      + Let readers subscribe to your blog via email, and optionally accept private replies. +

      +
      + Allow replies to this address: + +
      +
    • + {{end}} + {{if .Federation}} +
    • + + @{{.Alias}}@{{.FriendlyHost}} +

      Allow others to follow your blog and interact with your posts in the fediverse. See how it works.

      +
    • + {{end}} +
    +
    +
    +

    Display Format

    @@ -153,11 +191,20 @@ textarea.section.norm {
    +
    +

    Verification

    +
    +

    Verify that you own another site on the open web, fediverse, etc. For example, enter your Mastodon profile address here, then on Mastodon add a link back to this blog — it will show up as verified there.

    + +

    This adds a rel="me" code in your blog's <head>.

    +
    +
    + {{if .UserPage.StaticPage.AppCfg.Monetization}}

    Web Monetization

    -

    Web Monetization enables you to receive micropayments from readers that have a Coil membership. Add your payment pointer to enable Web Monetization on your blog.

    +

    Web Monetization enables you to receive micropayments from readers via Interledger. Add your payment pointer to enable Web Monetization on your blog.

    @@ -245,6 +292,13 @@ var $customDomain = document.getElementById('domain-alias'); var $customHandleEnv = document.getElementById('custom-handle-env'); var $normalHandleEnv = document.getElementById('normal-handle-env'); +var $emailSubsCheck = document.getElementById('email_subs'); +var $letterReply = document.getElementById('letter_reply'); +H.getEl('email_subs').on('click', function() { + let show = $emailSubsCheck.checked + $letterReply.disabled = !show +}) + if (matchMedia('(pointer:fine)').matches) { // Only initialize Ace editor on devices with a mouse var opt = { diff --git a/templates/user/include/header.tmpl b/templates/user/include/header.tmpl index 66a2a84..9b6912b 100644 --- a/templates/user/include/header.tmpl +++ b/templates/user/include/header.tmpl @@ -18,6 +18,7 @@ diff --git a/templates/user/include/nav.tmpl b/templates/user/include/nav.tmpl index 057fc3c..735019d 100644 --- a/templates/user/include/nav.tmpl +++ b/templates/user/include/nav.tmpl @@ -9,6 +9,7 @@ {{if .CanPost}}New Post{{end}} Customize Stats + Subscribers View Blog → diff --git a/templates/user/settings.tmpl b/templates/user/settings.tmpl index 338ea9a..829e4be 100644 --- a/templates/user/settings.tmpl +++ b/templates/user/settings.tmpl @@ -55,7 +55,7 @@ h3 { font-weight: normal; }

    Passphrase

    - {{if and (not .HasPass) (not .IsLogOut)}}

    Add a passphrase to easily log in to your account.

    {{end}} + {{if and (not .HasPass) (not .IsLogOut)}}

    Add a passphrase to easily log into your account.

    {{end}} {{if .HasPass}}

    Current passphrase

    New passphrase

    diff --git a/templates/user/stats.tmpl b/templates/user/stats.tmpl index 0791f77..b7f3322 100644 --- a/templates/user/stats.tmpl +++ b/templates/user/stats.tmpl @@ -30,15 +30,17 @@ td.none { {{end}}

    Stats for all time.

    - - {{if .Federation}} -

    Fediverse stats

    + + {{if or .Federation .EmailEnabled}} +

    Subscribers

    - + {{if .Federation}}{{end}} + {{if .EmailEnabled}}{{end}} - + {{if .Federation}}{{end}} + {{if .EmailEnabled}}{{end}}
    FollowersFediverse FollowersEmail Subscribers
    {{.APFollowers}}{{.APFollowers}}{{.EmailSubscribers}}
    {{end}} diff --git a/templates/user/subscribers.tmpl b/templates/user/subscribers.tmpl new file mode 100644 index 0000000..1e79ddb --- /dev/null +++ b/templates/user/subscribers.tmpl @@ -0,0 +1,98 @@ +{{define "subscribers"}} +{{template "header" .}} + + + +
    + {{if .Silenced}} + {{template "user-silenced"}} + {{end}} + + {{if .Collection.Collection}}{{template "collection-breadcrumbs" .}}{{end}} + +

    Subscribers

    + {{if .Collection.Collection}} + {{template "collection-nav" .Collection}} + + + {{end}} + + {{if .Flashes -}} +
      + {{range .Flashes}}
    • {{.}}
    • {{end}} +
    + {{- end}} + + {{ if eq .Filter "fediverse" }} + + + + + + + {{if and (gt (len .Followers) 0) (not .FederationEnabled)}} +
    +

    Federation is disabled on this server, so followers won't receive any new posts.

    +
    + {{end}} + {{ if gt (len .Followers) 0 }} + {{range $el := .Followers}} + + + + + {{end}} + {{ else }} + + + + {{ end }} +
    UsernameSince
    @{{.EstimatedHandle}}{{.CreatedFriendly}}
    No followers yet.
    + {{ else }} + {{if or .CanEmailSub .EmailSubs}} + {{if not .CanEmailSub}} +
    +

    Email subscriptions are disabled on this server, so no new emails will be sent out.

    +
    + {{end}} + {{if not .EmailSubsEnabled}} +
    +

    Email subscriptions are disabled. {{if .EmailSubs}}No new emails will be sent out.{{end}} To enable email subscriptions, turn the option on from your blog's Customize page.

    +
    + {{end}} + + + + + + + {{ if .EmailSubs }} + {{range $el := .EmailSubs}} + + + + + {{end}} + {{ else }} + + + + {{ end }} +
    Email AddressSince
    {{.Email.String}}{{.SubscribedFriendly}}
    No subscribers yet.
    + {{end}} + {{ end }} + +
    + +{{template "foot" .}} + +{{template "body-end" .}} +{{end}} diff --git a/updates.go b/updates.go index 574a91c..e29e13b 100644 --- a/updates.go +++ b/updates.go @@ -12,7 +12,7 @@ package writefreely import ( "github.com/writeas/web-core/log" - "io/ioutil" + "io" "net/http" "strings" "sync" @@ -121,7 +121,7 @@ func newVersionCheck() (string, error) { if err == nil && res.StatusCode == http.StatusOK { defer res.Body.Close() - body, err := ioutil.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) if err != nil { return "", err } diff --git a/users.go b/users.go index 05c7f24..449427d 100644 --- a/users.go +++ b/users.go @@ -134,3 +134,7 @@ func (u *User) IsAdmin() bool { func (u *User) IsSilenced() bool { return u.Status&UserSilenced != 0 } + +func (u *User) IsEmailSubscriber(app *App, collID int64) bool { + return app.db.IsEmailSubscriber("", u.ID, collID) +} diff --git a/webfinger.go b/webfinger.go index 023c8a5..0c52f72 100644 --- a/webfinger.go +++ b/webfinger.go @@ -12,7 +12,7 @@ package writefreely import ( "encoding/json" - "io/ioutil" + "io" "net/http" "strings" @@ -110,7 +110,7 @@ func RemoteLookup(handle string) string { return "" } - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { log.Error("Error on webfinger response: %v", err) return ""