Merge branch 'develop' into letters

This commit is contained in:
Matt Baer 2023-09-25 13:59:46 -04:00
commit 06968e7341
35 changed files with 586 additions and 127 deletions

View File

@ -86,6 +86,7 @@ release : clean ui
cp -r templates $(BUILDPATH) cp -r templates $(BUILDPATH)
cp -r pages $(BUILDPATH) cp -r pages $(BUILDPATH)
cp -r static $(BUILDPATH) cp -r static $(BUILDPATH)
rm -r $(BUILDPATH)/static/local
scripts/invalidate-css.sh $(BUILDPATH) scripts/invalidate-css.sh $(BUILDPATH)
mkdir $(BUILDPATH)/keys mkdir $(BUILDPATH)/keys
$(MAKE) build-linux $(MAKE) build-linux

View File

@ -862,9 +862,6 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
return ErrCollectionNotFound return ErrCollectionNotFound
} }
// Add collection properties
c.Monetization = app.db.GetCollectionAttribute(c.ID, "monetization_pointer")
silenced, err := app.db.IsUserSilenced(u.ID) silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil { if err != nil {
if err == ErrUserNotFound { if err == ErrUserNotFound {

View File

@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"io" "io"
"io/ioutil"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -100,7 +99,7 @@ func handleImport(app *App, u *User, w http.ResponseWriter, r *http.Request) err
} }
defer file.Close() defer file.Close()
tempFile, err := ioutil.TempFile("", "post-upload-*.txt") tempFile, err := os.CreateTemp("", "post-upload-*.txt")
if err != nil { if err != nil {
fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename)) fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename))
log.Error("import file: create temp file %s: %v", formFile.Filename, err) log.Error("import file: create temp file %s: %v", formFile.Filename, err)

View File

@ -17,22 +17,25 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil" "io"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/writeas/activity/streams" "github.com/writeas/activity/streams"
"github.com/writeas/activityserve"
"github.com/writeas/httpsig" "github.com/writeas/httpsig"
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/web-core/activitypub" "github.com/writeas/web-core/activitypub"
"github.com/writeas/web-core/activitystreams" "github.com/writeas/web-core/activitystreams"
"github.com/writeas/web-core/id" "github.com/writeas/web-core/id"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/web-core/silobridge"
) )
const ( const (
@ -60,6 +63,7 @@ type RemoteUser struct {
ActorID string ActorID string
Inbox string Inbox string
SharedInbox string SharedInbox string
URL string
Handle string Handle string
} }
@ -452,7 +456,7 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
followerID = remoteUser.ID followerID = remoteUser.ID
} else { } else {
// Add follower locally, since it wasn't found before // 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 err != nil {
// if duplicate key, res will be nil and panic on // if duplicate key, res will be nil and panic on
// res.LastInsertId below // res.LastInsertId below
@ -549,7 +553,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m
defer resp.Body.Close() defer resp.Body.Close()
} }
body, err := ioutil.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return err return err
} }
@ -601,7 +605,7 @@ func resolveIRI(hostName, url string) ([]byte, error) {
defer resp.Body.Close() defer resp.Body.Close()
} }
body, err := ioutil.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -644,10 +648,7 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
for si, instFolls := range inboxes { for si, instFolls := range inboxes {
na.CC = []string{} na.CC = []string{}
for _, f := range instFolls { na.CC = append(na.CC, instFolls...)
na.CC = append(na.CC, f)
}
da := activitystreams.NewDeleteActivity(na) da := activitystreams.NewDeleteActivity(na)
// Make the ID unique to ensure it works in Pleroma // Make the ID unique to ensure it works in Pleroma
// See: https://git.pleroma.social/pleroma/pleroma/issues/1481 // 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 // add all followers from that instance
// to the CC field // to the CC field
na.CC = []string{} na.CC = []string{}
for _, f := range instFolls { na.CC = append(na.CC, instFolls...)
na.CC = append(na.CC, f)
}
// create a new "Create" activity // create a new "Create" activity
// with our article as object // with our article as object
if isUpdate { 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) { func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
u := RemoteUser{ActorID: actorID} u := RemoteUser{ActorID: actorID}
var handle sql.NullString var urlVal, 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) 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 { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."} 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 return nil, err
} }
u.URL = urlVal.String
u.Handle = handle.String u.Handle = handle.String
return &u, nil return &u, nil
@ -783,7 +783,8 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
// from the @user@server.tld handle // from the @user@server.tld handle
func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) { func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) {
u := RemoteUser{Handle: handle} 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 { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
return nil, ErrRemoteUserNotFound 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) log.Error("Couldn't get remote user %s: %v", handle, err)
return nil, err return nil, err
} }
u.URL = urlVal.String
return &u, nil return &u, nil
} }
@ -824,6 +826,69 @@ func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser,
return actor, remoteUser, nil 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 // unmarshal actor normalizes the actor response to conform to
// the type Person from github.com/writeas/web-core/activitysteams // the type Person from github.com/writeas/web-core/activitysteams
// //

View File

@ -13,6 +13,7 @@ package writefreely
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"runtime" "runtime"
"strconv" "strconv"
@ -102,13 +103,16 @@ func NewAdminPage(app *App) *AdminPage {
return ap 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 // TODO: accept a locale in this method and use that for the format
var loc monday.Locale = monday.LocaleEnUS var loc monday.Locale = monday.LocaleEnUS
return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc) return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc)
*/ */
return c.Updated.Format("January 2, 2006, 3:04 PM") if c.Updated.IsZero() {
return "<em>Never</em>"
}
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 { 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 // Add in default pages
var hasAbout, hasPrivacy bool var hasAbout, hasContact, hasPrivacy bool
for i, c := range p.Pages { for i, c := range p.Pages {
if hasAbout && hasPrivacy { if hasAbout && hasContact && hasPrivacy {
break break
} }
if c.ID == "about" { if c.ID == "about" {
@ -436,6 +440,11 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ
if !c.Title.Valid { if !c.Title.Valid {
p.Pages[i].Title = defaultAboutTitle(app.cfg) 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" { } else if c.ID == "privacy" {
hasPrivacy = true hasPrivacy = true
if !c.Title.Valid { if !c.Title.Valid {
@ -451,6 +460,13 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ
Updated: defaultPageUpdatedTime, Updated: defaultPageUpdatedTime,
}) })
} }
if !hasContact {
p.Pages = append(p.Pages, &instanceContent{
ID: "contact",
Title: defaultContactTitle(),
Content: defaultContactPage(app),
})
}
if !hasPrivacy { if !hasPrivacy {
p.Pages = append(p.Pages, &instanceContent{ p.Pages = append(p.Pages, &instanceContent{
ID: "privacy", 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 // Get pre-defined pages, or select slug
if slug == "about" { if slug == "about" {
p.Content, err = getAboutPage(app) p.Content, err = getAboutPage(app)
} else if slug == "contact" {
p.Content, err = getContactPage(app)
} else if slug == "privacy" { } else if slug == "privacy" {
p.Content, err = getPrivacyPage(app) p.Content, err = getPrivacyPage(app)
} else if slug == "landing" { } else if slug == "landing" {
@ -523,7 +541,7 @@ func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Req
id := vars["page"] id := vars["page"]
// Validate // 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."} return impart.HTTPError{http.StatusNotFound, "No such page."}
} }

23
app.go
View File

@ -16,7 +16,6 @@ import (
_ "embed" _ "embed"
"fmt" "fmt"
"html/template" "html/template"
"io/ioutil"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
@ -59,7 +58,7 @@ var (
debugging bool debugging bool
// Software version can be set from git env using -ldflags // Software version can be set from git env using -ldflags
softwareVer = "0.13.2" softwareVer = "0.14.0"
// DEPRECATED VARS // DEPRECATED VARS
isSingleUser bool isSingleUser bool
@ -177,7 +176,7 @@ func (app *App) LoadKeys() error {
executable = filepath.Base(executable) executable = filepath.Base(executable)
} }
app.keys.EmailKey, err = ioutil.ReadFile(emailKeyPath) app.keys.EmailKey, err = os.ReadFile(emailKeyPath)
if err != nil { if err != nil {
return err return err
} }
@ -185,7 +184,7 @@ func (app *App) LoadKeys() error {
if debugging { if debugging {
log.Info(" %s", cookieAuthKeyPath) log.Info(" %s", cookieAuthKeyPath)
} }
app.keys.CookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath) app.keys.CookieAuthKey, err = os.ReadFile(cookieAuthKeyPath)
if err != nil { if err != nil {
return err return err
} }
@ -193,7 +192,7 @@ func (app *App) LoadKeys() error {
if debugging { if debugging {
log.Info(" %s", cookieKeyPath) log.Info(" %s", cookieKeyPath)
} }
app.keys.CookieKey, err = ioutil.ReadFile(cookieKeyPath) app.keys.CookieKey, err = os.ReadFile(cookieKeyPath)
if err != nil { if err != nil {
return err return err
} }
@ -201,7 +200,7 @@ func (app *App) LoadKeys() error {
if debugging { if debugging {
log.Info(" %s", csrfKeyPath) log.Info(" %s", csrfKeyPath)
} }
app.keys.CSRFKey, err = ioutil.ReadFile(csrfKeyPath) app.keys.CSRFKey, err = os.ReadFile(csrfKeyPath)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
log.Error(`Missing key: %s. 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), 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 c *instanceContent
var err error var err error
@ -329,6 +328,12 @@ func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *te
p.AboutStats = &InstanceStats{} p.AboutStats = &InstanceStats{}
p.AboutStats.NumPosts, _ = app.db.GetTotalPosts() p.AboutStats.NumPosts, _ = app.db.GetTotalPosts()
p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections() 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 { } else {
c, err = getPrivacyPage(app) c, err = getPrivacyPage(app)
} }
@ -580,8 +585,8 @@ func (app *App) InitDecoder() {
// tests the connection. // tests the connection.
func ConnectToDatabase(app *App) error { func ConnectToDatabase(app *App) error {
// Check database configuration // Check database configuration
if app.cfg.Database.Type == driverMySQL && (app.cfg.Database.User == "" || app.cfg.Database.Password == "") { if app.cfg.Database.Type == driverMySQL && app.cfg.Database.User == "" {
return fmt.Errorf("Database user or password not set.") return fmt.Errorf("Database user not set.")
} }
if app.cfg.Database.Host == "" { if app.cfg.Database.Host == "" {
app.cfg.Database.Host = "localhost" app.cfg.Database.Host = "localhost"

View File

@ -29,6 +29,7 @@ import (
"github.com/writeas/web-core/activitystreams" "github.com/writeas/web-core/activitystreams"
"github.com/writeas/web-core/auth" "github.com/writeas/web-core/auth"
"github.com/writeas/web-core/bots" "github.com/writeas/web-core/bots"
"github.com/writeas/web-core/i18n"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/web-core/posts" "github.com/writeas/web-core/posts"
"github.com/writefreely/writefreely/author" "github.com/writefreely/writefreely/author"
@ -62,6 +63,7 @@ type (
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
Monetization string `json:"monetization_pointer,omitempty"` Monetization string `json:"monetization_pointer,omitempty"`
Verification string `json:"verification_link"`
db *datastore db *datastore
hostName string hostName string
@ -76,6 +78,7 @@ type (
DisplayCollection struct { DisplayCollection struct {
*CollectionObj *CollectionObj
Prefix string Prefix string
NavSuffix string
IsTopLevel bool IsTopLevel bool
CurrentPage int CurrentPage int
TotalPages int TotalPages int
@ -102,6 +105,7 @@ type (
Script *sql.NullString `schema:"script" json:"script"` Script *sql.NullString `schema:"script" json:"script"`
Signature *sql.NullString `schema:"signature" json:"signature"` Signature *sql.NullString `schema:"signature" json:"signature"`
Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"` Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"`
Verification *string `schema:"verification_link" json:"verification_link"`
LetterReply *string `schema:"letter_reply" json:"letter_reply"` LetterReply *string `schema:"letter_reply" json:"letter_reply"`
Visibility *int `schema:"visibility" json:"public"` Visibility *int `schema:"visibility" json:"public"`
Format *sql.NullString `schema:"format" json:"format"` 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, // PrevPageURL provides a full URL for the previous page of collection posts,
// returning a /page/N result for pages >1 // 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 := "" u := ""
if n == 2 { if n == 2 {
// Previous page is 1; no need for /page/ prefix // Previous page is 1; no need for /page/ prefix
if prefix == "" { if prefix == "" {
u = "/" u = navSuffix + "/"
} }
// Else leave off trailing slash // Else leave off trailing slash
} else { } else {
u = fmt.Sprintf("/page/%d", n-1) u = fmt.Sprintf("%s/page/%d", navSuffix, n-1)
} }
if tl { 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 // 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 { 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 { func (c *Collection) DisplayTitle() string {
@ -396,6 +401,16 @@ func (c CollectionPage) DisplayMonetization() string {
return displayMonetization(c.Monetization, c.Alias) 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 { func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r) reqJSON := IsJSON(r)
alias := r.FormValue("alias") 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. // fetchCollection handles the API endpoint for retrieving collection data.
func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error { func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
accept := r.Header.Get("Accept") if IsActivityPubRequest(r) {
if strings.Contains(accept, "application/activity+json") {
return handleFetchCollectionActivities(app, w, r) return handleFetchCollectionActivities(app, w, r)
} }
@ -623,6 +637,30 @@ type CollectionPage struct {
CollAlias string 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 { func NewCollectionObj(c *Collection) *CollectionObj {
return &CollectionObj{ return &CollectionObj{
Collection: *c, Collection: *c,
@ -970,16 +1008,29 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
coll := newDisplayCollection(c, cr, page) 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) coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, page, cr.isCollOwner)
if coll.Posts != nil && len(*coll.Posts) == 0 { if coll.Posts != nil && len(*coll.Posts) == 0 {
return ErrCollectionPageNotFound return ErrCollectionPageNotFound
} }
// Serve collection // Serve collection
displayPage := struct { displayPage := TagCollectionPage{
CollectionPage
Tag string
}{
CollectionPage: CollectionPage{ CollectionPage: CollectionPage{
DisplayCollection: coll, DisplayCollection: coll,
StaticPage: pageForReq(app, r), StaticPage: pageForReq(app, r),
@ -1031,6 +1082,111 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
return nil 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 { func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r) vars := mux.Vars(r)
slug := vars["slug"] 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 != nil {
if err, ok := err.(impart.HTTPError); ok { if err, ok := err.(impart.HTTPError); ok {
if reqJSON { if reqJSON {

View File

@ -57,7 +57,7 @@ func Configure(fname string, configSections string) (*SetupData, error) {
Success: "{{ . | bold | faint }}: ", Success: "{{ . | bold | faint }}: ",
} }
selTmpls := &promptui.SelectTemplates{ selTmpls := &promptui.SelectTemplates{
Selected: fmt.Sprintf(`{{.Label}} {{ . | faint }}`), Selected: `{{.Label}} {{ . | faint }}`,
} }
var selPrompt promptui.Select var selPrompt promptui.Select

View File

@ -18,6 +18,7 @@ import (
"github.com/writeas/web-core/silobridge" "github.com/writeas/web-core/silobridge"
wf_db "github.com/writefreely/writefreely/db" wf_db "github.com/writefreely/writefreely/db"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@ -96,7 +97,7 @@ type writestore interface {
GetCollection(alias string) (*Collection, error) GetCollection(alias string) (*Collection, error)
GetCollectionForPad(alias string) (*Collection, error) GetCollectionForPad(alias string) (*Collection, error)
GetCollectionByID(id int64) (*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 DeleteCollection(alias string, userID int64) error
UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error
@ -114,6 +115,7 @@ type writestore interface {
GetPostsCount(c *CollectionObj, includeFuture bool) GetPostsCount(c *CollectionObj, includeFuture bool)
GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error) 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) GetPostsTagged(cfg *config.Config, c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error)
GetAPFollowers(c *Collection) (*[]RemoteUser, error) GetAPFollowers(c *Collection) (*[]RemoteUser, error)
@ -815,6 +817,7 @@ func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Coll
c.Format = format.String c.Format = format.String
c.Public = c.IsPublic() c.Public = c.IsPublic()
c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer") c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer")
c.Verification = db.GetCollectionAttribute(c.ID, "verification_link")
c.db = db c.db = db
@ -851,7 +854,7 @@ func (db *datastore) GetCollectionFromDomain(host string) (*Collection, error) {
return db.GetCollectionBy("host = ?", host) 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(). q := query.NewUpdate().
SetStringPtr(c.Title, "title"). SetStringPtr(c.Title, "title").
SetStringPtr(c.Description, "description"). 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 // Update Monetization value
if c.Monetization != nil { if c.Monetization != nil {
skipUpdate := false skipUpdate := false
@ -1231,6 +1272,51 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu
return &posts, nil 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 // GetPostsTagged retrieves all posts on the given Collection that contain the
// given tag. // given tag.
// It will return future posts if `includeFuture` is true. // 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 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) { 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) 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 { 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 { 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 { if err != nil {
log.Error("Unable to INSERT into collectionattributes: %v", err) log.Error("Unable to INSERT into collectionattributes: %v", err)
return err return err
@ -2801,6 +2955,7 @@ func handleFailedPostInsert(err error) error {
return err 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) { func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, error) {
handle = strings.TrimLeft(handle, "@") handle = strings.TrimLeft(handle, "@")
actorIRI := "" actorIRI := ""

View File

@ -247,10 +247,7 @@ func (b *CreateTableSqlBuilder) ToSQL() (string, error) {
} }
things = append(things, columnStr) things = append(things, columnStr)
} }
for _, constraint := range b.Constraints { things = append(things, b.Constraints...)
things = append(things, constraint)
}
if thingLen := len(things); thingLen > 0 { if thingLen := len(things); thingLen > 0 {
str.WriteString(" ( ") str.WriteString(" ( ")
for i, thing := range things { for i, thing := range things {

4
go.mod
View File

@ -37,13 +37,13 @@ require (
github.com/stretchr/testify v1.8.4 github.com/stretchr/testify v1.8.4
github.com/urfave/cli/v2 v2.25.7 github.com/urfave/cli/v2 v2.25.7
github.com/writeas/activity v0.1.2 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-strip-markdown/v2 v2.1.1
github.com/writeas/go-webfinger v1.1.0 github.com/writeas/go-webfinger v1.1.0
github.com/writeas/httpsig v1.0.0 github.com/writeas/httpsig v1.0.0
github.com/writeas/impart v1.1.1 github.com/writeas/impart v1.1.1
github.com/writeas/import v0.2.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/saturday v1.7.2-0.20200427193424-392b95a03320
github.com/writeas/slug v1.2.0 github.com/writeas/slug v1.2.0
github.com/writeas/web-core v1.5.0 github.com/writeas/web-core v1.5.0

6
go.sum
View File

@ -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/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 h1:BiSivIxLQFcKoUorpNN3rNwwFG5bITPnqUSyIccfdh0=
github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o= 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 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28=
github.com/writeas/go-strip-markdown/v2 v2.1.1/go.mod h1:UvvgPJgn1vvN8nWuE5e7v/+qmDu3BSVnKAB6Gl7hFzA= 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= 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/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 h1:3k+bDNCyqaWdZinyUZtEO4je3mR6fr/nE4ozTh9/9Wg=
github.com/writeas/import v0.2.1/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM= 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 v1.3.0 h1:h51wJ0DULXIDZ1w11zutLL7YCBRO5LznXISSzqVLZeA=
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219/go.mod h1:NyM35ayknT7lzO6O/1JpfgGyv+0W9Z9q7aE0J8bXxfQ= 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 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o=
github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA= 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= github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=

View File

@ -13,7 +13,6 @@ package writefreely
import ( import (
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writefreely/writefreely/key" "github.com/writefreely/writefreely/key"
"io/ioutil"
"os" "os"
"path/filepath" "path/filepath"
) )
@ -65,7 +64,7 @@ func generateKey(path string) error {
log.Error("FAILED. %s. Run writefreely --gen-keys again.", err) log.Error("FAILED. %s. Run writefreely --gen-keys again.", err)
return err return err
} }
err = ioutil.WriteFile(path, b, 0600) err = os.WriteFile(path, b, 0600)
if err != nil { if err != nil {
log.Error("FAILED writing file: %s", err) log.Error("FAILED writing file: %s", err)
return err return err

View File

@ -67,7 +67,8 @@ var migrations = []Migration{
New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9 New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9
New("support post signatures", supportPostSignatures), // V9 -> V10 (v0.13.0) New("support post signatures", supportPostSignatures), // V9 -> V10 (v0.13.0)
New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11 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 // CurrentVer returns the current migration version the application is on

View File

@ -1,5 +1,5 @@
/* /*
* Copyright © 2021 A Bunch Tell LLC. * Copyright © 2023 Musing Studio LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -10,41 +10,14 @@
package migrations package migrations
func supportLetters(db *datastore) error { func fediverseVerifyProfile(db *datastore) error {
t, err := db.Begin() t, err := db.Begin()
if err != nil { if err != nil {
t.Rollback() t.Rollback()
return err return err
} }
_, err = t.Exec(`CREATE TABLE publishjobs ( _, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN url ` + db.typeVarChar(255) + ` NULL` + db.after("shared_inbox"))
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)
)`)
if err != nil { if err != nil {
t.Rollback() t.Rollback()
return err return err

11
migrations/v13.go Normal file
View File

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

View File

@ -16,7 +16,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"io/ioutil" "io"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@ -144,7 +144,7 @@ func verifyReceipt(receipt, id string) error {
defer resp.Body.Close() defer resp.Body.Close()
} }
body, err := ioutil.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
log.Error("Unable to read %s response body: %s", receiptsHost, err) log.Error("Unable to read %s response body: %s", receiptsHost, err)
return err return err

View File

@ -94,14 +94,20 @@ INNER JOIN collections c
ON collection_id = c.id ON collection_id = c.id
WHERE collection_id IS NOT NULL WHERE collection_id IS NOT NULL
AND updated > DATE_SUB(NOW(), INTERVAL 6 MONTH)) co`).Scan(&activeHalfYear) 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 ( err = r.db.QueryRow(`SELECT COUNT(*) FROM (
SELECT DISTINCT collection_id SELECT DISTINCT collection_id
FROM posts FROM posts
INNER JOIN FROM collections c INNER JOIN collections c
ON collection_id = c.id ON collection_id = c.id
WHERE collection_id IS NOT NULL WHERE collection_id IS NOT NULL
AND updated > DATE_SUB(NOW(), INTERVAL 1 MONTH)) co`).Scan(&activeMonth) 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{ return nodeinfo.Usage{

View File

@ -15,7 +15,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"io/ioutil"
"net/http" "net/http"
"net/url" "net/url"
"strings" "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 { func limitedJsonUnmarshal(body io.ReadCloser, n int, thing interface{}) error {
lr := io.LimitReader(body, int64(n+1)) lr := io.LimitReader(body, int64(n+1))
data, err := ioutil.ReadAll(lr) data, err := io.ReadAll(lr)
if err != nil { if err != nil {
return err return err
} }

View File

@ -40,6 +40,28 @@ func defaultAboutTitle(cfg *config.Config) sql.NullString {
return sql.NullString{String: "About " + cfg.App.SiteName, Valid: true} 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) { func getPrivacyPage(app *App) (*instanceContent, error) {
c, err := app.db.GetDynamicContent("privacy") c, err := app.db.GetDynamicContent("privacy")
if err != nil { 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).` 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 { 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. return `[WriteFreely](https://writefreely.org), the software that powers this site, is built to enforce your right to privacy by default.

View File

@ -2,9 +2,7 @@
{{define "content"}} {{define "content"}}
<div class="content-container tight"> <div class="content-container tight">
<h1>Server error &#x1F635;</h1> <h1>Server error &#x1F635;</h1>
<p>Please <a href="https://github.com/writefreely/writefreely/issues/new">contact the human authors</a> of this software and remind them of their many shortcomings.</p> <p>There seems to be an issue with this server. Please <a href="/contact">contact the admin</a> and let them know they'll need to fix it.</p>
<p>Be gentle, though. They are fragile mortal beings.</p>
<p style="margin-top:2em">Also, unlike the AI that will soon replace them, you will need to include an error log from the server in your report. (Utterly <em>primitive</em>, we know.)</p>
<p>&ndash; {{.SiteName}} &#x1F916;</p> <p>&ndash; {{.SiteName}} &#x1F916;</p>
</div> </div>
{{end}} {{end}}

8
pages/contact.tmpl Normal file
View File

@ -0,0 +1,8 @@
{{define "head"}}<title>{{.ContentTitle}} &mdash; {{.SiteName}}</title>
<meta name="description" content="{{.PlainContent}}">
{{end}}
{{define "content"}}<div class="content-container snug">
<h1>{{.ContentTitle}}</h1>
{{.Content}}
</div>
{{end}}

View File

@ -120,7 +120,7 @@ func (p *PublicPost) augmentReadingDestination() {
} }
func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string { 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 { func disableYoutubeAutoplay(outHTML string) string {
@ -142,7 +142,7 @@ func disableYoutubeAutoplay(outHTML string) string {
return outHTML 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 | mdExtensions := 0 |
blackfriday.EXTENSION_TABLES | blackfriday.EXTENSION_TABLES |
blackfriday.EXTENSION_FENCED_CODE | blackfriday.EXTENSION_FENCED_CODE |

View File

@ -140,6 +140,7 @@ type (
IsPinned bool IsPinned bool
IsCustomDomain bool IsCustomDomain bool
Monetization string Monetization string
Verification string
PinnedPosts *[]PublicPost PinnedPosts *[]PublicPost
IsFound bool IsFound bool
IsAdmin 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)} 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 { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
found = false 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. // newPost creates a new post with or without an owning Collection.
// //
// Endpoints: // Endpoints:
// /posts // - /posts
// /posts?collection={alias} // - /posts?collection={alias}
// ? /collections/{alias}/posts // - ? /collections/{alias}/posts
func newPost(app *App, w http.ResponseWriter, r *http.Request) error { func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r) reqJSON := IsJSON(r)
vars := mux.Vars(r) vars := mux.Vars(r)
@ -1136,8 +1137,7 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
p.extractData() p.extractData()
accept := r.Header.Get("Accept") if IsActivityPubRequest(r) {
if strings.Contains(accept, "application/activity+json") {
if coll == nil { if coll == nil {
// This is a draft post; 404 for now // This is a draft post; 404 for now
// TODO: return ActivityObject // TODO: return ActivityObject
@ -1582,7 +1582,8 @@ Are you sure it was ever here?`,
tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin) tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)
tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner) tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner)
tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p) 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 { if !postFound {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)

View File

@ -13,6 +13,7 @@ package writefreely
import ( import (
"mime" "mime"
"net/http" "net/http"
"strings"
) )
func IsJSON(r *http.Request) bool { func IsJSON(r *http.Request) bool {
@ -20,3 +21,9 @@ func IsJSON(r *http.Request) bool {
accept := r.Header.Get("Accept") accept := r.Header.Get("Accept")
return ct == "application/json" || accept == "application/json" 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\""
}

View File

@ -219,7 +219,10 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
func RouteCollections(handler *Handler, r *mux.Router) { func RouteCollections(handler *Handler, r *mux.Router) {
r.HandleFunc("/logout", handler.Web(handleLogOutCollection, UserLevelOptional)) r.HandleFunc("/logout", handler.Web(handleLogOutCollection, UserLevelOptional))
r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader)) 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}", 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("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader))
r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap)) r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap))
r.HandleFunc("/feed/", handler.AllReader(ViewFeed)) r.HandleFunc("/feed/", handler.AllReader(ViewFeed))

View File

@ -14,9 +14,8 @@ import (
"errors" "errors"
"html/template" "html/template"
"io" "io"
"io/ioutil"
"net/http"
"os" "os"
"net/http"
"path/filepath" "path/filepath"
"strings" "strings"
@ -120,7 +119,7 @@ func initUserPage(parentDir, path, key string) {
// InitTemplates loads all template files from the configured parent dir. // InitTemplates loads all template files from the configured parent dir.
func InitTemplates(cfg *config.Config) error { func InitTemplates(cfg *config.Config) error {
log.Info("Loading templates...") 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 { if err != nil {
return err return err
} }

View File

@ -9,8 +9,8 @@
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}} {{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
<link rel="canonical" href="{{.CanonicalURL}}"> <link rel="canonical" href="{{.CanonicalURL}}">
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}} {{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{end}}
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}} {{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{end}}
{{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} &raquo; Feed" href="{{.CanonicalURL}}feed/" />{{end}} {{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} &raquo; Feed" href="{{.CanonicalURL}}feed/" />{{end}}
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -92,11 +92,11 @@ body#collection header nav.tabs a:first-child {
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix"> {{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
{{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}} {{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}}
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">&#8672; {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}} {{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">&#8672; {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} &#8674;</a>{{end}} {{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} &#8674;</a>{{end}}
{{else}} {{else}}
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">&#8672; Older</a>{{end}} {{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">&#8672; Older</a>{{end}}
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">Newer &#8674;</a>{{end}} {{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">Newer &#8674;</a>{{end}}
{{end}} {{end}}
</nav>{{end}} </nav>{{end}}

View File

@ -61,6 +61,17 @@
{{if .Posts}}<section id="wrapper" itemscope itemtype="http://schema.org/Blog">{{else}}<div id="wrapper">{{end}} {{if .Posts}}<section id="wrapper" itemscope itemtype="http://schema.org/Blog">{{else}}<div id="wrapper">{{end}}
<h1>{{.Tag}}</h1> <h1>{{.Tag}}</h1>
{{template "posts" .}} {{template "posts" .}}
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
{{if or (and .Format.Ascending (lt .CurrentPage .TotalPages)) (isRTL .Direction)}}
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">&#8672; {{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} &#8674;</a>{{end}}
{{else}}
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">&#8672; Older</a>{{end}}
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">Newer &#8674;</a>{{end}}
{{end}}
</nav>{{end}}
{{if .Posts}}</section>{{else}}</div>{{end}} {{if .Posts}}</section>{{else}}</div>{{end}}
{{ if .Collection.ShowFooterBranding }} {{ if .Collection.ShowFooterBranding }}

View File

@ -9,8 +9,8 @@
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}} {{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
<link rel="canonical" href="{{.CanonicalURL}}"> <link rel="canonical" href="{{.CanonicalURL}}">
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}} {{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{end}}
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}} {{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{end}}
{{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} &raquo; Feed" href="{{.CanonicalURL}}feed/" />{{end}} {{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} &raquo; Feed" href="{{.CanonicalURL}}feed/" />{{end}}
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -113,11 +113,11 @@
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix"> {{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
{{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}} {{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}}
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">&#8672; {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}} {{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">&#8672; {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} &#8674;</a>{{end}} {{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} &#8674;</a>{{end}}
{{else}} {{else}}
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">&#8672; Older</a>{{end}} {{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">&#8672; Older</a>{{end}}
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">Newer &#8674;</a>{{end}} {{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">Newer &#8674;</a>{{end}}
{{end}} {{end}}
</nav>{{end}} </nav>{{end}}

View File

@ -3,6 +3,9 @@
{{if .Monetization -}} {{if .Monetization -}}
<meta name="monetization" content="{{.DisplayMonetization}}" /> <meta name="monetization" content="{{.DisplayMonetization}}" />
{{- end}} {{- end}}
{{if .Verification -}}
<link rel="me" href="{{.Verification}}" />
{{- end}}
{{end}} {{end}}
{{define "highlighting"}} {{define "highlighting"}}

View File

@ -29,6 +29,8 @@ input[type=text] {
{{if eq .Content.ID "about"}} {{if eq .Content.ID "about"}}
<p class="page-desc content-desc">Describe what your instance is <a href="/about" target="page">about</a>.</p> <p class="page-desc content-desc">Describe what your instance is <a href="/about" target="page">about</a>.</p>
{{else if eq .Content.ID "contact"}}
<p class="page-desc content-desc">Tell your users and outside visitors how to <a href="/contact" target="page">contact</a> you.</p>
{{else if eq .Content.ID "privacy"}} {{else if eq .Content.ID "privacy"}}
<p class="page-desc content-desc">Outline your <a href="/privacy" target="page">privacy policy</a>.</p> <p class="page-desc content-desc">Outline your <a href="/privacy" target="page">privacy policy</a>.</p>
{{else if eq .Content.ID "reader"}} {{else if eq .Content.ID "reader"}}

View File

@ -191,6 +191,15 @@ textarea.section.norm {
</div> </div>
</div> </div>
<div class="option">
<h2>Verification</h2>
<div class="section">
<p class="explain">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 &mdash; it will show up as <a href="https://joinmastodon.org/verification" target="mastoverified">verified</a> there.</p>
<input type="text" name="verification_link" style="width:100%" value="{{.Verification}}" placeholder="https://writing.exchange/@writefreely" />
<p class="explain">This adds a <code>rel="me"</code> code in your blog's <code>&lt;head&gt;</code>.</p>
</div>
</div>
{{if .UserPage.StaticPage.AppCfg.Monetization}} {{if .UserPage.StaticPage.AppCfg.Monetization}}
<div class="option"> <div class="option">
<h2>Web Monetization</h2> <h2>Web Monetization</h2>

View File

@ -12,7 +12,7 @@ package writefreely
import ( import (
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"io/ioutil" "io"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
@ -121,7 +121,7 @@ func newVersionCheck() (string, error) {
if err == nil && res.StatusCode == http.StatusOK { if err == nil && res.StatusCode == http.StatusOK {
defer res.Body.Close() defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body) body, err := io.ReadAll(res.Body)
if err != nil { if err != nil {
return "", err return "", err
} }

View File

@ -12,7 +12,7 @@ package writefreely
import ( import (
"encoding/json" "encoding/json"
"io/ioutil" "io"
"net/http" "net/http"
"strings" "strings"
@ -110,7 +110,7 @@ func RemoteLookup(handle string) string {
return "" return ""
} }
body, err := ioutil.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
log.Error("Error on webfinger response: %v", err) log.Error("Error on webfinger response: %v", err)
return "" return ""