diff --git a/Makefile b/Makefile index c599a58..b230cb7 100644 --- a/Makefile +++ b/Makefile @@ -86,6 +86,7 @@ release : clean ui 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 e5c8d34..97d3a62 100644 --- a/account.go +++ b/account.go @@ -862,9 +862,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 { 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 7d475e3..54a6ccc 100644 --- a/app.go +++ b/app.go @@ -16,7 +16,6 @@ import ( _ "embed" "fmt" "html/template" - "io/ioutil" "net" "net/http" "net/url" @@ -59,7 +58,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 @@ -177,7 +176,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 } @@ -185,7 +184,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 } @@ -193,7 +192,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 } @@ -201,7 +200,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. @@ -318,7 +317,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 @@ -329,6 +328,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) } @@ -580,8 +585,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" diff --git a/collections.go b/collections.go index 48e5f67..b95c20d 100644 --- a/collections.go +++ b/collections.go @@ -29,6 +29,7 @@ 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" "github.com/writeas/web-core/posts" "github.com/writefreely/writefreely/author" @@ -62,6 +63,7 @@ type ( URL string `json:"url,omitempty"` Monetization string `json:"monetization_pointer,omitempty"` + Verification string `json:"verification_link"` db *datastore hostName string @@ -76,6 +78,7 @@ type ( DisplayCollection struct { *CollectionObj Prefix string + NavSuffix string IsTopLevel bool CurrentPage int TotalPages int @@ -102,6 +105,7 @@ 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"` @@ -264,16 +268,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 { @@ -283,11 +287,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 { @@ -396,6 +401,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") @@ -505,8 +520,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) } @@ -623,6 +637,30 @@ type CollectionPage struct { 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, @@ -970,16 +1008,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), @@ -1031,6 +1082,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"] @@ -1115,7 +1271,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/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 425066f..3a5b081 100644 --- a/database.go +++ b/database.go @@ -18,6 +18,7 @@ import ( "github.com/writeas/web-core/silobridge" wf_db "github.com/writefreely/writefreely/db" "net/http" + "net/url" "strings" "time" @@ -96,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 @@ -114,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) @@ -815,6 +817,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 @@ -851,7 +854,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"). @@ -910,6 +913,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 @@ -1231,6 +1272,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 <= 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. @@ -1296,6 +1382,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 { @@ -2264,7 +2418,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 @@ -2801,6 +2955,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 := "" 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/go.mod b/go.mod index 00edb9c..eeebd1d 100644 --- a/go.mod +++ b/go.mod @@ -37,13 +37,13 @@ require ( 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.5.0 diff --git a/go.sum b/go.sum index 444641d..1614dc1 100644 --- a/go.sum +++ b/go.sum @@ -171,6 +171,8 @@ github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7Dg 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/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= @@ -185,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= diff --git a/keys.go b/keys.go index 98ff13f..7674995 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" ) @@ -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/migrations/migrations.go b/migrations/migrations.go index d113842..d4f9d4b 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -67,7 +67,8 @@ var migrations = []Migration{ New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9 New("support post signatures", supportPostSignatures), // V9 -> V10 (v0.13.0) New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11 - New("support newsletters", supportLetters), // V11 -> V12 + New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 (v0.14.0) + New("support newsletters", supportLetters), // V12 -> V13 } // CurrentVer returns the current migration version the application is on diff --git a/migrations/v12.go b/migrations/v12.go index cb509f2..bed93fd 100644 --- a/migrations/v12.go +++ b/migrations/v12.go @@ -1,5 +1,5 @@ /* - * Copyright © 2021 A Bunch Tell LLC. + * Copyright © 2023 Musing Studio LLC. * * This file is part of WriteFreely. * @@ -10,41 +10,14 @@ package migrations -func supportLetters(db *datastore) error { +func fediverseVerifyProfile(db *datastore) error { t, err := db.Begin() if err != nil { t.Rollback() return err } - _, err = t.Exec(`CREATE TABLE publishjobs ( - id ` + db.typeInt() + ` auto_increment, - post_id ` + db.typeVarChar(16) + ` not null, - action ` + db.typeVarChar(16) + ` not null, - delay ` + db.typeTinyInt() + ` not null, - PRIMARY KEY (id) -)`) - if err != nil { - t.Rollback() - return err - } - - // TODO: fix for SQLite database - _, err = t.Exec(`CREATE TABLE emailsubscribers ( - id char(8) not null, - collection_id int not null, - user_id int null, - email varchar(255) null, - subscribed datetime not null, - token char(16) not null, - confirmed tinyint(1) default 0 not null, - allow_export tinyint(1) default 0 not null, - constraint eu_coll_email - unique (collection_id, email), - constraint eu_coll_user - unique (collection_id, user_id), - PRIMARY KEY (id) -)`) + _, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN url ` + db.typeVarChar(255) + ` NULL` + db.after("shared_inbox")) if err != nil { t.Rollback() return err diff --git a/migrations/v13.go b/migrations/v13.go new file mode 100644 index 0000000..40e8fa8 --- /dev/null +++ b/migrations/v13.go @@ -0,0 +1,11 @@ +/* + * Copyright © 2018-2023 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations 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/pages.go b/pages.go index 8b3a987..bf85526 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,6 +92,18 @@ 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. 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/postrender.go b/postrender.go index 8e4d706..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 | diff --git a/posts.go b/posts.go index 306f38c..6b94cf8 100644 --- a/posts.go +++ b/posts.go @@ -140,6 +140,7 @@ type ( IsPinned bool IsCustomDomain bool Monetization string + Verification string PinnedPosts *[]PublicPost IsFound bool IsAdmin bool @@ -355,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 @@ -517,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) @@ -1136,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 @@ -1582,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) 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 6f4e592..407d245 100644 --- a/routes.go +++ b/routes.go @@ -219,7 +219,10 @@ 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)) diff --git a/templates.go b/templates.go index e51317b..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 } 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/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 d014a7c..8380910 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}} @@ -113,11 +113,11 @@ {{if gt .TotalPages 1}}{{end}} diff --git a/templates/include/post-render.tmpl b/templates/include/post-render.tmpl index fd3cbf2..ff8c4c1 100644 --- a/templates/include/post-render.tmpl +++ b/templates/include/post-render.tmpl @@ -3,6 +3,9 @@ {{if .Monetization -}} {{- end}} + {{if .Verification -}} + + {{- end}} {{end}} {{define "highlighting"}} 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/collection.tmpl b/templates/user/collection.tmpl index 25047d6..f057bdd 100644 --- a/templates/user/collection.tmpl +++ b/templates/user/collection.tmpl @@ -191,6 +191,15 @@ 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

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/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 ""