@@ -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 | |||
@@ -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 { | |||
@@ -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) | |||
@@ -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 | |||
// | |||
@@ -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 "<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 { | |||
@@ -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."} | |||
} | |||
@@ -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" | |||
@@ -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,12 +1008,126 @@ 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 := TagCollectionPage{ | |||
CollectionPage: CollectionPage{ | |||
DisplayCollection: coll, | |||
StaticPage: pageForReq(app, r), | |||
IsCustomDomain: cr.isCustomDomain, | |||
}, | |||
Tag: tag, | |||
} | |||
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") | |||
err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage) | |||
if err != nil { | |||
log.Error("Unable to render collection tag page: %v", err) | |||
} | |||
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 | |||
@@ -985,7 +1137,7 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e | |||
StaticPage: pageForReq(app, r), | |||
IsCustomDomain: cr.isCustomDomain, | |||
}, | |||
Tag: tag, | |||
Tag: lang, | |||
} | |||
var owner *User | |||
if u != nil { | |||
@@ -1023,9 +1175,13 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e | |||
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) | |||
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") | |||
err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage) | |||
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 tag page: %v", err) | |||
log.Error("Unable to render collection lang page: %v", err) | |||
} | |||
return nil | |||
@@ -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 { | |||
@@ -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 | |||
@@ -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 := "" | |||
@@ -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 { | |||
@@ -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 | |||
@@ -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= | |||
@@ -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 | |||
@@ -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 | |||
@@ -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 | |||
@@ -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 |
@@ -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 | |||
@@ -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{ | |||
@@ -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 | |||
} | |||
@@ -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. | |||
@@ -2,9 +2,7 @@ | |||
{{define "content"}} | |||
<div class="content-container tight"> | |||
<h1>Server error 😵</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>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>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>– {{.SiteName}} 🤖</p> | |||
</div> | |||
{{end}} |
@@ -0,0 +1,8 @@ | |||
{{define "head"}}<title>{{.ContentTitle}} — {{.SiteName}}</title> | |||
<meta name="description" content="{{.PlainContent}}"> | |||
{{end}} | |||
{{define "content"}}<div class="content-container snug"> | |||
<h1>{{.ContentTitle}}</h1> | |||
{{.Content}} | |||
</div> | |||
{{end}} |
@@ -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 | | |||
@@ -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) | |||
@@ -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\"" | |||
} |
@@ -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)) | |||
@@ -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 | |||
} | |||
@@ -9,8 +9,8 @@ | |||
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}} | |||
<link rel="shortcut icon" href="/favicon.ico" /> | |||
<link rel="canonical" href="{{.CanonicalURL}}"> | |||
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}} | |||
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .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 .NavSuffix .CurrentPage .IsTopLevel}}">{{end}} | |||
{{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} » Feed" href="{{.CanonicalURL}}feed/" />{{end}} | |||
<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 or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}} | |||
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ {{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}} ⇢</a>{{end}} | |||
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</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}} ⇢</a>{{end}} | |||
{{else}} | |||
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}} | |||
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">Newer ⇢</a>{{end}} | |||
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}} | |||
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">Newer ⇢</a>{{end}} | |||
{{end}} | |||
</nav>{{end}} | |||
@@ -61,6 +61,17 @@ | |||
{{if .Posts}}<section id="wrapper" itemscope itemtype="http://schema.org/Blog">{{else}}<div id="wrapper">{{end}} | |||
<h1>{{.Tag}}</h1> | |||
{{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}}">⇠ {{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}} ⇢</a>{{end}} | |||
{{else}} | |||
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}} | |||
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">Newer ⇢</a>{{end}} | |||
{{end}} | |||
</nav>{{end}} | |||
{{if .Posts}}</section>{{else}}</div>{{end}} | |||
{{ if .Collection.ShowFooterBranding }} | |||
@@ -9,8 +9,8 @@ | |||
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}} | |||
<link rel="shortcut icon" href="/favicon.ico" /> | |||
<link rel="canonical" href="{{.CanonicalURL}}"> | |||
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}} | |||
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .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 .NavSuffix .CurrentPage .IsTopLevel}}">{{end}} | |||
{{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} » Feed" href="{{.CanonicalURL}}feed/" />{{end}} | |||
<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 or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}} | |||
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ {{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}} ⇢</a>{{end}} | |||
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</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}} ⇢</a>{{end}} | |||
{{else}} | |||
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}} | |||
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">Newer ⇢</a>{{end}} | |||
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}} | |||
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">Newer ⇢</a>{{end}} | |||
{{end}} | |||
</nav>{{end}} | |||
@@ -3,6 +3,9 @@ | |||
{{if .Monetization -}} | |||
<meta name="monetization" content="{{.DisplayMonetization}}" /> | |||
{{- end}} | |||
{{if .Verification -}} | |||
<link rel="me" href="{{.Verification}}" /> | |||
{{- end}} | |||
{{end}} | |||
{{define "highlighting"}} | |||
@@ -29,6 +29,8 @@ input[type=text] { | |||
{{if eq .Content.ID "about"}} | |||
<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"}} | |||
<p class="page-desc content-desc">Outline your <a href="/privacy" target="page">privacy policy</a>.</p> | |||
{{else if eq .Content.ID "reader"}} | |||
@@ -191,6 +191,15 @@ textarea.section.norm { | |||
</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 — 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><head></code>.</p> | |||
</div> | |||
</div> | |||
{{if .UserPage.StaticPage.AppCfg.Monetization}} | |||
<div class="option"> | |||
<h2>Web Monetization</h2> | |||
@@ -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 | |||
} | |||
@@ -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 "" | |||