diff --git a/posts.go b/posts.go index d004296..6cb76a2 100644 --- a/posts.go +++ b/posts.go @@ -1,1467 +1,1469 @@ /* * Copyright © 2018-2019 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 writefreely import ( "database/sql" "encoding/json" "fmt" "html/template" "net/http" "regexp" "strings" "time" "github.com/gorilla/mux" "github.com/guregu/null" "github.com/guregu/null/zero" "github.com/kylemcc/twitter-text-go/extract" "github.com/microcosm-cc/bluemonday" stripmd "github.com/writeas/go-strip-markdown" "github.com/writeas/impart" "github.com/writeas/monday" "github.com/writeas/slug" "github.com/writeas/web-core/activitystreams" "github.com/writeas/web-core/bots" "github.com/writeas/web-core/converter" "github.com/writeas/web-core/i18n" "github.com/writeas/web-core/log" "github.com/writeas/web-core/tags" "github.com/writeas/writefreely/config" "github.com/writeas/writefreely/page" "github.com/writeas/writefreely/parse" ) const ( // Post ID length bounds minIDLen = 10 maxIDLen = 10 userPostIDLen = 10 postIDLen = 10 postMetaDateFormat = "2006-01-02 15:04:05" ) type ( AnonymousPost struct { ID string Content string HTMLContent template.HTML Font string Language string Direction string Title string GenTitle string Description string Author string Views int64 IsPlainText bool IsCode bool IsLinkable bool } AuthenticatedPost struct { ID string `json:"id" schema:"id"` Web bool `json:"web" schema:"web"` *SubmittedPost } // SubmittedPost represents a post supplied by a client for publishing or // updating. Since Title and Content can be updated to "", they are // pointers that can be easily tested to detect changes. SubmittedPost struct { Slug *string `json:"slug" schema:"slug"` Title *string `json:"title" schema:"title"` Content *string `json:"body" schema:"body"` Font string `json:"font" schema:"font"` IsRTL converter.NullJSONBool `json:"rtl" schema:"rtl"` Language converter.NullJSONString `json:"lang" schema:"lang"` Created *string `json:"created" schema:"created"` } // Post represents a post as found in the database. Post struct { ID string `db:"id" json:"id"` Slug null.String `db:"slug" json:"slug,omitempty"` Font string `db:"text_appearance" json:"appearance"` Language zero.String `db:"language" json:"language"` RTL zero.Bool `db:"rtl" json:"rtl"` Privacy int64 `db:"privacy" json:"-"` OwnerID null.Int `db:"owner_id" json:"-"` CollectionID null.Int `db:"collection_id" json:"-"` PinnedPosition null.Int `db:"pinned_position" json:"-"` Created time.Time `db:"created" json:"created"` Updated time.Time `db:"updated" json:"updated"` ViewCount int64 `db:"view_count" json:"-"` Title zero.String `db:"title" json:"title"` HTMLTitle template.HTML `db:"title" json:"-"` Content string `db:"content" json:"body"` HTMLContent template.HTML `db:"content" json:"-"` HTMLExcerpt template.HTML `db:"content" json:"-"` Tags []string `json:"tags"` Images []string `json:"images,omitempty"` OwnerName string `json:"owner,omitempty"` } // PublicPost holds properties for a publicly returned post, i.e. a post in // a context where the viewer may not be the owner. As such, sensitive // metadata for the post is hidden and properties supporting the display of // the post are added. PublicPost struct { *Post IsSubdomain bool `json:"-"` IsTopLevel bool `json:"-"` DisplayDate string `json:"-"` Views int64 `json:"views"` Owner *PublicUser `json:"-"` IsOwner bool `json:"-"` Collection *CollectionObj `json:"collection,omitempty"` } RawPost struct { Id, Slug string Title string Content string Views int64 Font string Created time.Time IsRTL sql.NullBool Language sql.NullString OwnerID int64 CollectionID sql.NullInt64 Found bool Gone bool } AnonymousAuthPost struct { ID string `json:"id"` Token string `json:"token"` } ClaimPostRequest struct { *AnonymousAuthPost CollectionAlias string `json:"collection"` CreateCollection bool `json:"create_collection"` // Generated properties Slug string `json:"-"` } ClaimPostResult struct { ID string `json:"id,omitempty"` Code int `json:"code,omitempty"` ErrorMessage string `json:"error_msg,omitempty"` Post *PublicPost `json:"post,omitempty"` } ) func (p *Post) Direction() string { if p.RTL.Valid { if p.RTL.Bool { return "rtl" } return "ltr" } return "auto" } // DisplayTitle dynamically generates a title from the Post's contents if it // doesn't already have an explicit title. func (p *Post) DisplayTitle() string { if p.Title.String != "" { return p.Title.String } t := friendlyPostTitle(p.Content, p.ID) return t } // PlainDisplayTitle dynamically generates a title from the Post's contents if it // doesn't already have an explicit title. func (p *Post) PlainDisplayTitle() string { if t := stripmd.Strip(p.DisplayTitle()); t != "" { return t } return p.ID } // FormattedDisplayTitle dynamically generates a title from the Post's contents if it // doesn't already have an explicit title. func (p *Post) FormattedDisplayTitle() template.HTML { if p.HTMLTitle != "" { return p.HTMLTitle } return template.HTML(p.DisplayTitle()) } // Summary gives a shortened summary of the post based on the post's title, // especially for display in a longer list of posts. It extracts a summary for // posts in the Title\n\nBody format, returning nothing if the entire was short // enough that the extracted title == extracted summary. func (p Post) Summary() string { if p.Content == "" { return "" } // Strip out HTML p.Content = bluemonday.StrictPolicy().Sanitize(p.Content) // and Markdown p.Content = stripmd.Strip(p.Content) title := p.Title.String var desc string if title == "" { // No title, so generate one title = friendlyPostTitle(p.Content, p.ID) desc = postDescription(p.Content, title, p.ID) if desc == title { return "" } return desc } return shortPostDescription(p.Content) } // Excerpt shows any text that comes before a (more) tag. // TODO: use HTMLExcerpt in templates instead of this method func (p *Post) Excerpt() template.HTML { return p.HTMLExcerpt } func (p *Post) CreatedDate() string { return p.Created.Format("2006-01-02") } func (p *Post) Created8601() string { return p.Created.Format("2006-01-02T15:04:05Z") } func (p *Post) IsScheduled() bool { return p.Created.After(time.Now()) } func (p *Post) HasTag(tag string) bool { // Regexp looks for tag and has a non-capturing group at the end looking // for the end of the word. // Assisted by: https://stackoverflow.com/a/35192941/1549194 hasTag, _ := regexp.MatchString("#"+tag+`(?:[[:punct:]]|\s|\z)`, p.Content) return hasTag } func (p *Post) HasTitleLink() bool { if p.Title.String == "" { return false } hasLink, _ := regexp.MatchString(`([^!]+|^)\[.+\]\(.+\)`, p.Title.String) return hasLink } func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) friendlyID := vars["post"] // NOTE: until this is done better, be sure to keep this in parity with // isRaw() and viewCollectionPost() isJSON := strings.HasSuffix(friendlyID, ".json") isXML := strings.HasSuffix(friendlyID, ".xml") isCSS := strings.HasSuffix(friendlyID, ".css") isMarkdown := strings.HasSuffix(friendlyID, ".md") isRaw := strings.HasSuffix(friendlyID, ".txt") || isJSON || isXML || isCSS || isMarkdown // Display reserved page if that is requested resource if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok { return handleTemplatedPage(app, w, r, t) } else if (strings.Contains(r.URL.Path, ".") && !isRaw && !isMarkdown) || r.URL.Path == "/robots.txt" || r.URL.Path == "/manifest.json" { // Serve static file app.shttp.ServeHTTP(w, r) return nil } // Display collection if this is a collection c, _ := app.db.GetCollection(friendlyID) if c != nil { return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", friendlyID)} } // Normalize the URL, redirecting user to consistent post URL if friendlyID != strings.ToLower(friendlyID) { return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s", strings.ToLower(friendlyID))} } ext := "" if isRaw { parts := strings.Split(friendlyID, ".") friendlyID = parts[0] if len(parts) > 1 { ext = "." + parts[1] } } var ownerID sql.NullInt64 var title string var content string var font string var language []byte var rtl []byte var views int64 var post *AnonymousPost var found bool var gone bool fixedID := slug.Make(friendlyID) if fixedID != friendlyID { 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) switch { case err == sql.ErrNoRows: found = false // Output the error in the correct format if isJSON { content = "{\"error\": \"Post not found.\"}" } else if isRaw { content = "Post not found." } else { return ErrPostNotFound } case err != nil: found = false log.Error("Post loading err: %s\n", err) return ErrInternalGeneral default: found = true var d string if len(rtl) == 0 { d = "auto" } else if rtl[0] == 49 { // TODO: find a cleaner way to get this (possibly NULL) value d = "rtl" } else { d = "ltr" } generatedTitle := friendlyPostTitle(content, friendlyID) sanitizedContent := content if font != "code" { sanitizedContent = template.HTMLEscapeString(content) } var desc string if title == "" { desc = postDescription(content, title, friendlyID) } else { desc = shortPostDescription(content) } post = &AnonymousPost{ ID: friendlyID, Content: sanitizedContent, Title: title, GenTitle: generatedTitle, Description: desc, Author: "", Font: font, IsPlainText: isRaw, IsCode: font == "code", IsLinkable: font != "code", Views: views, Language: string(language), Direction: d, } if !isRaw { post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "", app.cfg)) } } // Check if post has been unpublished if content == "" { gone = true if isJSON { content = "{\"error\": \"Post was unpublished.\"}" } else if isCSS { content = "" } else if isRaw { content = "Post was unpublished." } else { return ErrPostUnpublished } } var u = &User{} if isRaw { contentType := "text/plain" if isJSON { contentType = "application/json" } else if isCSS { contentType = "text/css" } else if isXML { contentType = "application/xml" } else if isMarkdown { contentType = "text/markdown" } w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType)) if isMarkdown && post.Title != "" { fmt.Fprintf(w, "%s\n", post.Title) for i := 1; i <= len(post.Title); i++ { fmt.Fprintf(w, "=") } fmt.Fprintf(w, "\n\n") } fmt.Fprint(w, content) if !found { return ErrPostNotFound } else if gone { return ErrPostUnpublished } } else { var err error page := struct { *AnonymousPost page.StaticPage Username string IsOwner bool SiteURL string }{ AnonymousPost: post, StaticPage: pageForReq(app, r), SiteURL: app.cfg.App.Host, } if u = getUserSession(app, r); u != nil { page.Username = u.Username page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID } err = templates["post"].ExecuteTemplate(w, "post", page) if err != nil { log.Error("Post template execute error: %v", err) } } go func() { if u != nil && ownerID.Valid && ownerID.Int64 == u.ID { // Post is owned by someone; skip view increment since that person is viewing this post. return } // Update stats for non-raw post views if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) { _, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE id = ?", friendlyID) if err != nil { log.Error("Unable to update posts count: %v", err) } } }() return nil } // API v2 funcs // newPost creates a new post with or without an owning Collection. // // Endpoints: // /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) collAlias := vars["alias"] if collAlias == "" { collAlias = r.FormValue("collection") } accessToken := r.Header.Get("Authorization") if accessToken == "" { // TODO: remove this accessToken = r.FormValue("access_token") } // FIXME: determine web submission with Content-Type header var u *User var userID int64 = -1 var username string if accessToken == "" { u = getUserSession(app, r) if u != nil { userID = u.ID username = u.Username } } else { userID = app.db.GetUserID(accessToken) } if userID == -1 { return ErrNotLoggedIn } if accessToken == "" && u == nil && collAlias != "" { return impart.HTTPError{http.StatusBadRequest, "Parameter `access_token` required."} } // Get post data var p *SubmittedPost if reqJSON { decoder := json.NewDecoder(r.Body) err := decoder.Decode(&p) if err != nil { log.Error("Couldn't parse new post JSON request: %v\n", err) return ErrBadJSON } if p.Title == nil { t := "" p.Title = &t } if strings.TrimSpace(*(p.Content)) == "" { return ErrNoPublishableContent } } else { post := r.FormValue("body") appearance := r.FormValue("font") title := r.FormValue("title") rtlValue := r.FormValue("rtl") langValue := r.FormValue("lang") if strings.TrimSpace(post) == "" { return ErrNoPublishableContent } var isRTL, rtlValid bool if rtlValue == "auto" && langValue != "" { isRTL = i18n.LangIsRTL(langValue) rtlValid = true } else { isRTL = rtlValue == "true" rtlValid = rtlValue != "" && langValue != "" } // Create a new post p = &SubmittedPost{ Title: &title, Content: &post, Font: appearance, IsRTL: converter.NullJSONBool{sql.NullBool{Bool: isRTL, Valid: rtlValid}}, Language: converter.NullJSONString{sql.NullString{String: langValue, Valid: langValue != ""}}, } } if !p.isFontValid() { p.Font = "norm" } var newPost *PublicPost = &PublicPost{} var coll *Collection var err error if accessToken != "" { newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias, app.cfg.App.Host) } else { //return ErrNotLoggedIn // TODO: verify user is logged in var collID int64 if collAlias != "" { coll, err = app.db.GetCollection(collAlias) if err != nil { return err } coll.hostName = app.cfg.App.Host if coll.OwnerID != u.ID { return ErrForbiddenCollection } collID = coll.ID } // TODO: return PublicPost from createPost newPost.Post, err = app.db.CreatePost(userID, collID, p) } if err != nil { return err } if coll != nil { coll.ForPublic() newPost.Collection = &CollectionObj{Collection: *coll} } newPost.extractData() newPost.OwnerName = username // Write success now response := impart.WriteSuccess(w, newPost, http.StatusCreated) if newPost.Collection != nil && !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) { go federatePost(app, newPost, newPost.Collection.ID, false) } return response } func existingPost(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) vars := mux.Vars(r) postID := vars["post"] p := AuthenticatedPost{ID: postID} var err error if reqJSON { // Decode JSON request decoder := json.NewDecoder(r.Body) err = decoder.Decode(&p) if err != nil { log.Error("Couldn't parse post update JSON request: %v\n", err) return ErrBadJSON } } else { err = r.ParseForm() if err != nil { log.Error("Couldn't parse post update form request: %v\n", err) return ErrBadFormData } // Can't decode to a nil SubmittedPost property, so create instance now p.SubmittedPost = &SubmittedPost{} err = app.formDecoder.Decode(&p, r.PostForm) if err != nil { log.Error("Couldn't decode post update form request: %v\n", err) return ErrBadFormData } } if p.Web { p.IsRTL.Valid = true } if p.SubmittedPost == nil { return ErrPostNoUpdatableVals } // Ensure an access token was given accessToken := r.Header.Get("Authorization") // Get user's cookie session if there's no token var u *User //var username string if accessToken == "" { u = getUserSession(app, r) if u != nil { //username = u.Username } } if u == nil && accessToken == "" { return ErrNoAccessToken } // Get user ID from current session or given access token, if one was given. var userID int64 if u != nil { userID = u.ID } else if accessToken != "" { userID, err = AuthenticateUser(app.db, accessToken) if err != nil { return err } } // Modify post struct p.ID = postID err = app.db.UpdateOwnedPost(&p, userID) if err != nil { if reqJSON { return err } if err, ok := err.(impart.HTTPError); ok { addSessionFlash(app, w, r, err.Message, nil) } else { addSessionFlash(app, w, r, err.Error(), nil) } } var pRes *PublicPost pRes, err = app.db.GetPost(p.ID, 0) if reqJSON { if err != nil { return err } pRes.extractData() } if pRes.CollectionID.Valid { coll, err := app.db.GetCollectionBy("id = ?", pRes.CollectionID.Int64) if err == nil && !app.cfg.App.Private && app.cfg.App.Federation { coll.hostName = app.cfg.App.Host pRes.Collection = &CollectionObj{Collection: *coll} go federatePost(app, pRes, pRes.Collection.ID, true) } } // Write success now if reqJSON { return impart.WriteSuccess(w, pRes, http.StatusOK) } addSessionFlash(app, w, r, "Changes saved.", nil) collectionAlias := vars["alias"] redirect := "/" + postID + "/meta" if collectionAlias != "" { collPre := "/" + collectionAlias if app.cfg.App.SingleUser { collPre = "" } redirect = collPre + "/" + pRes.Slug.String + "/edit/meta" } else { if app.cfg.App.SingleUser { redirect = "/d" + redirect } } w.Header().Set("Location", redirect) w.WriteHeader(http.StatusFound) return nil } func deletePost(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) friendlyID := vars["post"] editToken := r.FormValue("token") var ownerID int64 var u *User accessToken := r.Header.Get("Authorization") if accessToken == "" && editToken == "" { u = getUserSession(app, r) if u == nil { return ErrNoAccessToken } } var res sql.Result var t *sql.Tx var err error var collID sql.NullInt64 var coll *Collection var pp *PublicPost if editToken != "" { // TODO: SELECT owner_id, as well, and return appropriate error if NULL instead of running two queries var dummy int64 err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ?", friendlyID).Scan(&dummy) switch { case err == sql.ErrNoRows: return impart.HTTPError{http.StatusNotFound, "Post not found."} } err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND owner_id IS NULL", friendlyID).Scan(&dummy) switch { case err == sql.ErrNoRows: // Post already has an owner. This could provide a bad experience // for the user, but it's more important to ensure data isn't lost // unexpectedly. So prevent deletion via token. return impart.HTTPError{http.StatusConflict, "This post belongs to some user (hopefully yours). Please log in and delete it from that user's account."} } res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND modify_token = ? AND owner_id IS NULL", friendlyID, editToken) } else if accessToken != "" || u != nil { // Caller provided some way to authenticate; assume caller expects the // post to be deleted based on a specific post owner, thus we should // return corresponding errors. if accessToken != "" { ownerID = app.db.GetUserID(accessToken) if ownerID == -1 { return ErrBadAccessToken } } else { ownerID = u.ID } // TODO: don't make two queries var realOwnerID sql.NullInt64 err = app.db.QueryRow("SELECT collection_id, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&collID, &realOwnerID) if err != nil { return err } if !collID.Valid { // There's no collection; simply delete the post res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID) } else { // Post belongs to a collection; do any additional clean up coll, err = app.db.GetCollectionBy("id = ?", collID.Int64) if err != nil { log.Error("Unable to get collection: %v", err) return err } if app.cfg.App.Federation { // First fetch full post for federation pp, err = app.db.GetOwnedPost(friendlyID, ownerID) if err != nil { log.Error("Unable to get owned post: %v", err) return err } collObj := &CollectionObj{Collection: *coll} pp.Collection = collObj } t, err = app.db.Begin() if err != nil { log.Error("No begin: %v", err) return err } res, err = t.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID) } } else { return impart.HTTPError{http.StatusBadRequest, "No authenticated user or post token given."} } if err != nil { return err } affected, err := res.RowsAffected() if err != nil { if t != nil { t.Rollback() log.Error("Rows affected err! Rolling back") } return err } else if affected == 0 { if t != nil { t.Rollback() log.Error("No rows affected! Rolling back") } return impart.HTTPError{http.StatusForbidden, "Post not found, or you're not the owner."} } if t != nil { t.Commit() } if coll != nil && !app.cfg.App.Private && app.cfg.App.Federation { go deleteFederatedPost(app, pp, collID.Int64) } return impart.HTTPError{Status: http.StatusNoContent} } // addPost associates a post with the authenticated user. func addPost(app *App, w http.ResponseWriter, r *http.Request) error { var ownerID int64 // Authenticate user at := r.Header.Get("Authorization") if at != "" { ownerID = app.db.GetUserID(at) if ownerID == -1 { return ErrBadAccessToken } } else { u := getUserSession(app, r) if u == nil { return ErrNotLoggedIn } ownerID = u.ID } // Parse claimed posts in format: // [{"id": "...", "token": "..."}] var claims *[]ClaimPostRequest decoder := json.NewDecoder(r.Body) err := decoder.Decode(&claims) if err != nil { return ErrBadJSONArray } vars := mux.Vars(r) collAlias := vars["alias"] // Update all given posts res, err := app.db.ClaimPosts(app.cfg, ownerID, collAlias, claims) if err != nil { return err } if !app.cfg.App.Private && app.cfg.App.Federation { for _, pRes := range *res { if pRes.Code != http.StatusOK { continue } if !pRes.Post.Created.After(time.Now()) { pRes.Post.Collection.hostName = app.cfg.App.Host go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false) } } } return impart.WriteSuccess(w, res, http.StatusOK) } func dispersePost(app *App, w http.ResponseWriter, r *http.Request) error { var ownerID int64 // Authenticate user at := r.Header.Get("Authorization") if at != "" { ownerID = app.db.GetUserID(at) if ownerID == -1 { return ErrBadAccessToken } } else { u := getUserSession(app, r) if u == nil { return ErrNotLoggedIn } ownerID = u.ID } // Parse posts in format: // ["..."] var postIDs []string decoder := json.NewDecoder(r.Body) err := decoder.Decode(&postIDs) if err != nil { return ErrBadJSONArray } // Update all given posts res, err := app.db.DispersePosts(ownerID, postIDs) if err != nil { return err } return impart.WriteSuccess(w, res, http.StatusOK) } type ( PinPostResult struct { ID string `json:"id,omitempty"` Code int `json:"code,omitempty"` ErrorMessage string `json:"error_msg,omitempty"` } ) // pinPost pins a post to a blog func pinPost(app *App, w http.ResponseWriter, r *http.Request) error { var userID int64 // Authenticate user at := r.Header.Get("Authorization") if at != "" { userID = app.db.GetUserID(at) if userID == -1 { return ErrBadAccessToken } } else { u := getUserSession(app, r) if u == nil { return ErrNotLoggedIn } userID = u.ID } // Parse request var posts []struct { ID string `json:"id"` Position int64 `json:"position"` } decoder := json.NewDecoder(r.Body) err := decoder.Decode(&posts) if err != nil { return ErrBadJSONArray } // Validate data vars := mux.Vars(r) collAlias := vars["alias"] coll, err := app.db.GetCollection(collAlias) if err != nil { return err } if coll.OwnerID != userID { return ErrForbiddenCollection } // Do (un)pinning isPinning := r.URL.Path[strings.LastIndex(r.URL.Path, "/"):] == "/pin" res := []PinPostResult{} for _, p := range posts { err = app.db.UpdatePostPinState(isPinning, p.ID, coll.ID, userID, p.Position) ppr := PinPostResult{ID: p.ID} if err != nil { ppr.Code = http.StatusInternalServerError // TODO: set error messsage } else { ppr.Code = http.StatusOK } res = append(res, ppr) } return impart.WriteSuccess(w, res, http.StatusOK) } func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error { var collID int64 var coll *Collection var err error vars := mux.Vars(r) if collAlias := vars["alias"]; collAlias != "" { // Fetch collection information, since an alias is provided coll, err = app.db.GetCollection(collAlias) if err != nil { return err } coll.hostName = app.cfg.App.Host _, err = apiCheckCollectionPermissions(app, r, coll) if err != nil { return err } collID = coll.ID } p, err := app.db.GetPost(vars["post"], collID) if err != nil { return err } p.extractData() accept := r.Header.Get("Accept") if strings.Contains(accept, "application/activity+json") { // Fetch information about the collection this belongs to if coll == nil && p.CollectionID.Valid { coll, err = app.db.GetCollectionByID(p.CollectionID.Int64) if err != nil { return err } } if coll == nil { // This is a draft post; 404 for now // TODO: return ActivityObject return impart.HTTPError{http.StatusNotFound, ""} } p.Collection = &CollectionObj{Collection: *coll} po := p.ActivityObject(app.cfg) po.Context = []interface{}{activitystreams.Namespace} return impart.RenderActivityJSON(w, po, http.StatusOK) } return impart.WriteSuccess(w, p, http.StatusOK) } func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) p, err := app.db.GetPostProperty(vars["post"], 0, vars["property"]) if err != nil { return err } return impart.WriteSuccess(w, p, http.StatusOK) } func (p *Post) processPost() PublicPost { res := &PublicPost{Post: p, Views: 0} res.Views = p.ViewCount // TODO: move to own function loc := monday.FuzzyLocale(p.Language.String) res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc) return *res } func (p *PublicPost) CanonicalURL(hostName string) string { if p.Collection == nil || p.Collection.Alias == "" { return hostName + "/" + p.ID } return p.Collection.CanonicalURL() + p.Slug.String } func (p *PublicPost) ActivityObject(cfg *config.Config) *activitystreams.Object { o := activitystreams.NewArticleObject() o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID o.Published = p.Created o.URL = p.CanonicalURL(cfg.App.Host) o.AttributedTo = p.Collection.FederatedAccount() o.CC = []string{ p.Collection.FederatedAccount() + "/followers", } o.Name = p.DisplayTitle() if p.HTMLContent == template.HTML("") { p.formatContent(cfg, false) } o.Content = string(p.HTMLContent) if p.Language.Valid { o.ContentMap = map[string]string{ p.Language.String: string(p.HTMLContent), } } if len(p.Tags) == 0 { o.Tag = []activitystreams.Tag{} } else { var tagBaseURL string if isSingleUser { tagBaseURL = p.Collection.CanonicalURL() + "tag:" } else { if cfg.App.Chorus { tagBaseURL = fmt.Sprintf("%s/read/t/", p.Collection.hostName) } else { tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias) } } for _, t := range p.Tags { o.Tag = append(o.Tag, activitystreams.Tag{ Type: activitystreams.TagHashtag, HRef: tagBaseURL + t, Name: "#" + t, }) } } return o } // TODO: merge this into getSlugFromPost or phase it out func getSlug(title, lang string) string { return getSlugFromPost("", title, lang) } func getSlugFromPost(title, body, lang string) string { if title == "" { title = postTitle(body, body) } title = parse.PostLede(title, false) // Truncate lede if needed title, _ = parse.TruncToWord(title, 80) var s string if lang != "" && len(lang) == 2 { s = slug.MakeLang(title, lang) } else { s = slug.Make(title) } // Transliteration may cause the slug to expand past the limit, so truncate again s, _ = parse.TruncToWord(s, 80) return strings.TrimFunc(s, func(r rune) bool { // TruncToWord doesn't respect words in a slug, since spaces are replaced // with hyphens. So remove any trailing hyphens. return r == '-' }) } // isFontValid returns whether or not the submitted post's appearance is valid. func (p *SubmittedPost) isFontValid() bool { validFonts := map[string]bool{ "norm": true, "sans": true, "mono": true, "wrap": true, "code": true, } _, valid := validFonts[p.Font] return valid } func getRawPost(app *App, friendlyID string) *RawPost { var content, font, title string var isRTL sql.NullBool var lang sql.NullString var ownerID sql.NullInt64 var created time.Time err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &ownerID) switch { case err == sql.ErrNoRows: return &RawPost{Content: "", Found: false, Gone: false} case err != nil: return &RawPost{Content: "", Found: true, Gone: false} } return &RawPost{Title: title, Content: content, Font: font, Created: created, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""} } // TODO; return a Post! func getRawCollectionPost(app *App, slug, collAlias string) *RawPost { var id, title, content, font string var isRTL sql.NullBool var lang sql.NullString var created time.Time var ownerID null.Int var views int64 var err error if app.cfg.App.SingleUser { err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID) } else { err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = (SELECT id FROM collections WHERE alias = ?)", slug, collAlias).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID) } switch { case err == sql.ErrNoRows: return &RawPost{Content: "", Found: false, Gone: false} case err != nil: return &RawPost{Content: "", Found: true, Gone: false} } return &RawPost{ Id: id, Slug: slug, Title: title, Content: content, Font: font, Created: created, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == "", Views: views, } } func isRaw(r *http.Request) bool { vars := mux.Vars(r) slug := vars["slug"] // NOTE: until this is done better, be sure to keep this in parity with // isRaw in viewCollectionPost() and handleViewPost() isJSON := strings.HasSuffix(slug, ".json") isXML := strings.HasSuffix(slug, ".xml") isMarkdown := strings.HasSuffix(slug, ".md") return strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown } func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) slug := vars["slug"] // NOTE: until this is done better, be sure to keep this in parity with // isRaw() and handleViewPost() isJSON := strings.HasSuffix(slug, ".json") isXML := strings.HasSuffix(slug, ".xml") isMarkdown := strings.HasSuffix(slug, ".md") isRaw := strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown cr := &collectionReq{} err := processCollectionRequest(cr, vars, w, r) if err != nil { return err } // Check for hellbanned users u, err := checkUserForCollection(app, cr, r, true) if err != nil { return err } // Normalize the URL, redirecting user to consistent post URL if slug != strings.ToLower(slug) { loc := fmt.Sprintf("/%s", strings.ToLower(slug)) if !app.cfg.App.SingleUser { loc = "/" + cr.alias + loc } return impart.HTTPError{http.StatusMovedPermanently, loc} } // Display collection if this is a collection var c *Collection if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(cr.alias) } if err != nil { if err, ok := err.(impart.HTTPError); ok { if err.Status == http.StatusNotFound { // Redirect if necessary newAlias := app.db.GetCollectionRedirect(cr.alias) if newAlias != "" { return impart.HTTPError{http.StatusFound, "/" + newAlias + "/" + slug} } } } return err } c.hostName = app.cfg.App.Host // Check collection permissions if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) { return ErrPostNotFound } if c.IsProtected() && ((u == nil || u.ID != c.OwnerID) && !isAuthorizedForCollection(app, c.Alias, r)) { return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug} } cr.isCollOwner = u != nil && c.OwnerID == u.ID if isRaw { slug = strings.Split(slug, ".")[0] } // Fetch extra data about the Collection // TODO: refactor out this logic, shared in collection.go:fetchCollection() coll := &CollectionObj{Collection: *c} 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) } else { coll.Owner = owner } postFound := true p, err := app.db.GetPost(slug, coll.ID) if err != nil { if err == ErrCollectionPageNotFound { postFound = false if slug == "feed" { // User tried to access blog feed without a trailing slash, and // there's no post with a slug "feed" return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/feed/"} } po := &Post{ Slug: null.NewString(slug, true), Font: "norm", Language: zero.NewString("en", true), RTL: zero.NewBool(false, true), Content: `<p class="msg">This page is missing.</p> Are you sure it was ever here?`, } pp := po.processPost() p = &pp } else { return err } } p.IsOwner = owner != nil && p.OwnerID.Valid && owner.ID == p.OwnerID.Int64 p.Collection = coll p.IsTopLevel = app.cfg.App.SingleUser // Check if post has been unpublished if p.Content == "" && p.Title.String == "" { return impart.HTTPError{http.StatusGone, "Post was unpublished."} } // Serve collection post if isRaw { contentType := "text/plain" if isJSON { contentType = "application/json" } else if isXML { contentType = "application/xml" } else if isMarkdown { contentType = "text/markdown" } w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType)) if !postFound { w.WriteHeader(http.StatusNotFound) fmt.Fprintf(w, "Post not found.") // TODO: return error instead, so status is correctly reflected in logs return nil } if isMarkdown && p.Title.String != "" { fmt.Fprintf(w, "# %s\n\n", p.Title.String) } fmt.Fprint(w, p.Content) } else if strings.Contains(r.Header.Get("Accept"), "application/activity+json") { if !postFound { return ErrCollectionPageNotFound } p.extractData() ap := p.ActivityObject(app.cfg) ap.Context = []interface{}{activitystreams.Namespace} return impart.RenderActivityJSON(w, ap, http.StatusOK) } else { p.extractData() p.Content = strings.Replace(p.Content, "<!--more-->", "", 1) // TODO: move this to function p.formatContent(app.cfg, cr.isCollOwner) tp := struct { *PublicPost page.StaticPage IsOwner bool IsPinned bool IsCustomDomain bool PinnedPosts *[]PublicPost IsFound bool IsAdmin bool CanInvite bool + Hostname string }{ PublicPost: p, StaticPage: pageForReq(app, r), IsOwner: cr.isCollOwner, IsCustomDomain: cr.isCustomDomain, IsFound: postFound, + Hostname: app.cfg.App.Host, } tp.IsAdmin = u != nil && u.IsAdmin() 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) if !postFound { w.WriteHeader(http.StatusNotFound) } postTmpl := "collection-post" if app.cfg.App.Chorus { postTmpl = "chorus-collection-post" } if err := templates[postTmpl].ExecuteTemplate(w, "post", tp); err != nil { log.Error("Error in collection-post template: %v", err) } } go func() { if p.OwnerID.Valid { // Post is owned by someone. Don't update stats if owner is viewing the post. if u != nil && p.OwnerID.Int64 == u.ID { return } } // Update stats for non-raw post views if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) { _, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE slug = ? AND collection_id = ?", slug, coll.ID) if err != nil { log.Error("Unable to update posts count: %v", err) } } }() return nil } // TODO: move this to utils after making it more generic func PostsContains(sl *[]PublicPost, s *PublicPost) bool { for _, e := range *sl { if e.ID == s.ID { return true } } return false } func (p *Post) extractData() { p.Tags = tags.Extract(p.Content) p.extractImages() } func (rp *RawPost) UserFacingCreated() string { return rp.Created.Format(postMetaDateFormat) } func (rp *RawPost) Created8601() string { return rp.Created.Format("2006-01-02T15:04:05Z") } var imageURLRegex = regexp.MustCompile(`(?i)^https?:\/\/[^ ]*\.(gif|png|jpg|jpeg|image)$`) func (p *Post) extractImages() { matches := extract.ExtractUrls(p.Content) urls := map[string]bool{} for i := range matches { u := matches[i].Text if !imageURLRegex.MatchString(u) { continue } urls[u] = true } resURLs := make([]string, 0) for k := range urls { resURLs = append(resURLs, k) } p.Images = resURLs } diff --git a/templates/chorus-collection-post.tmpl b/templates/chorus-collection-post.tmpl index bab2e31..d229c62 100644 --- a/templates/chorus-collection-post.tmpl +++ b/templates/chorus-collection-post.tmpl @@ -1,150 +1,150 @@ {{define "post"}}<!DOCTYPE HTML> <html {{if .Language.Valid}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}"> <head prefix="og: http://ogp.me/ns# article: http://ogp.me/ns/article#"> <meta charset="utf-8"> <title>{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}}</title> <link rel="stylesheet" type="text/css" href="/css/write.css" /> <link rel="shortcut icon" href="/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <link rel="canonical" href="{{.CanonicalURL}}" /> + <link rel="canonical" href="{{.CanonicalURL .Hostname}}" /> <meta name="generator" content="WriteFreely"> <meta name="title" content="{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{if .Collection.Title}}{{.Collection.Title}}{{else}}{{.Collection.Alias}}{{end}}"> <meta name="description" content="{{.Summary}}"> {{if gt .Views 1}}<meta name="twitter:label1" value="Views"> <meta name="twitter:data1" value="{{largeNumFmt .Views}}">{{end}} <meta name="author" content="{{.Collection.Title}}" /> <meta itemprop="description" content="{{.Summary}}"> <meta itemprop="datePublished" content="{{.CreatedDate}}" /> <meta name="twitter:card" content="summary"> <meta name="twitter:description" content="{{.Summary}}"> <meta name="twitter:title" content="{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{if .Collection.Title}}{{.Collection.Title}}{{else}}{{.Collection.Alias}}{{end}}"> {{if gt (len .Images) 0}}<meta name="twitter:image" content="{{index .Images 0}}">{{else}}<meta name="twitter:image" content="{{.Collection.AvatarURL}}">{{end}} <meta property="og:title" content="{{.PlainDisplayTitle}}" /> <meta property="og:description" content="{{.Summary}}" /> <meta property="og:site_name" content="{{.Collection.DisplayTitle}}" /> <meta property="og:type" content="article" /> - <meta property="og:url" content="{{.CanonicalURL}}" /> + <meta property="og:url" content="{{.CanonicalURL .Hostname}}" /> <meta property="og:updated_time" content="{{.Created8601}}" /> {{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Collection.AvatarURL}}">{{end}} <meta property="article:published_time" content="{{.Created8601}}"> {{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}} <style type="text/css"> body footer { max-width: 40rem; margin: 0 auto; } body#post header { padding: 1em 1rem; } article time.dt-published { display: block; color: #666; } body#post article h2#title{ margin-bottom: 0.5em; } article time.dt-published { margin-bottom: 1em; } </style> {{if .Collection.RenderMathJax}} <!-- Add mathjax logic --> {{template "mathjax" . }} {{end}} <!-- Add highlighting logic --> {{template "highlighting" .}} </head> <body id="post"> <div id="overlay"></div> {{template "user-navigation" .}} <article id="post-body" class="{{.Font}} h-entry">{{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}{{if .Title.String}}<h2 id="title" class="p-name">{{.FormattedDisplayTitle}}</h2>{{end}}{{/* TODO: check format: if .Collection.Format.ShowDates*/}}<time class="dt-published" datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}">{{.DisplayDate}}</time><div class="e-content">{{.HTMLContent}}</div></article> {{ if .Collection.ShowFooterBranding }} <footer dir="ltr"> <p style="text-align: left">Published by <a rel="author" href="{{if .IsTopLevel}}/{{else}}/{{.Collection.Alias}}/{{end}}" class="h-card p-author">{{.Collection.DisplayTitle}}</a> {{ if .IsOwner }} · <span class="views" dir="ltr"><strong>{{largeNumFmt .Views}}</strong> {{pluralize "view" "views" .Views}}</span> · <a class="xtra-feature" href="/{{if not .SingleUser}}{{.Collection.Alias}}/{{end}}{{.Slug.String}}/edit" dir="{{.Direction}}">Edit</a> {{if .IsPinned}} · <a class="xtra-feature unpin" href="/{{.Collection.Alias}}/{{.Slug.String}}/unpin" dir="{{.Direction}}" onclick="unpinPost(event, '{{.ID}}')">Unpin</a>{{end}} {{ end }} </p> <nav> {{if .PinnedPosts}} - {{range .PinnedPosts}}<a class="pinned{{if eq .Slug.String $.Slug.String}} selected{{end}}" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}} + {{range .PinnedPosts}}<a class="pinned{{if eq .Slug.String $.Slug.String}} selected{{end}}" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL .Hostname}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}} {{end}} </nav> <hr> <nav><p style="font-size: 0.9em">{{localhtml "published with write.as" .Language.String}}</p></nav> </footer> {{ end }} </body> {{if .Collection.CanShowScript}} {{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}} {{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}} {{end}} <script type="text/javascript"> var pinning = false; function unpinPost(e, postID) { e.preventDefault(); if (pinning) { return; } pinning = true; var $footer = document.getElementsByTagName('footer')[0]; var callback = function() { // Hide current page var $pinnedNavLink = $footer.getElementsByTagName('nav')[0].querySelector('.pinned.selected'); $pinnedNavLink.style.display = 'none'; }; var $pinBtn = $footer.getElementsByClassName('unpin')[0]; $pinBtn.innerHTML = '...'; var http = new XMLHttpRequest(); var url = "/api/collections/{{.Collection.Alias}}/unpin"; var params = [ { "id": postID } ]; http.open("POST", url, true); http.setRequestHeader("Content-type", "application/json"); http.onreadystatechange = function() { if (http.readyState == 4) { pinning = false; if (http.status == 200) { callback(); $pinBtn.style.display = 'none'; $pinBtn.innerHTML = 'Pin'; } else if (http.status == 409) { $pinBtn.innerHTML = 'Unpin'; } else { $pinBtn.innerHTML = 'Unpin'; alert("Failed to unpin." + (http.status>=500?" Please try again.":"")); } } } http.send(JSON.stringify(params)); }; try { // Fonts WebFontConfig = { custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] } }; (function() { var wf = document.createElement('script'); wf.src = '/js/webfont.js'; wf.type = 'text/javascript'; wf.async = 'true'; var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(wf, s); })(); } catch (e) { /* ¯\_(ツ)_/¯ */ } </script> </html>{{end}} diff --git a/templates/chorus-collection.tmpl b/templates/chorus-collection.tmpl index e36d3b5..ebee403 100644 --- a/templates/chorus-collection.tmpl +++ b/templates/chorus-collection.tmpl @@ -1,230 +1,230 @@ {{define "collection"}}<!DOCTYPE HTML> <html {{if .Language}}lang="{{.Language}}"{{end}} dir="{{.Direction}}"> <head> <meta charset="utf-8"> <title>{{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}}</title> <link rel="stylesheet" type="text/css" href="/css/write.css" /> <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 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" /> <meta name="generator" content="WriteFreely"> <meta name="description" content="{{.Description}}"> <meta itemprop="name" content="{{.DisplayTitle}}"> <meta itemprop="description" content="{{.Description}}"> <meta name="twitter:card" content="summary"> <meta name="twitter:title" content="{{.DisplayTitle}}"> <meta name="twitter:image" content="{{.AvatarURL}}"> <meta name="twitter:description" content="{{.Description}}"> <meta property="og:title" content="{{.DisplayTitle}}" /> <meta property="og:site_name" content="{{.DisplayTitle}}" /> <meta property="og:type" content="article" /> <meta property="og:url" content="{{.CanonicalURL}}" /> <meta property="og:description" content="{{.Description}}" /> <meta property="og:image" content="{{.AvatarURL}}"> {{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}} <style type="text/css"> body#collection header { max-width: 40em; margin: 1em auto; text-align: left; padding: 0; } body#collection header.multiuser { max-width: 100%; margin: 1em; } body#collection header nav:not(.pinned-posts) { display: inline; } body#collection header nav.dropdown-nav, body#collection header nav.tabs, body#collection header nav.tabs a:first-child { margin: 0 0 0 1em; } </style> {{if .RenderMathJax}} <!-- Add mathjax logic --> {{template "mathjax" .}} {{end}} <!-- Add highlighting logic --> {{template "highlighting" . }} </head> <body id="collection" itemscope itemtype="http://schema.org/WebPage"> {{template "user-navigation" .}} <header> <h1 dir="{{.Direction}}" id="blog-title"><a href="/{{if .IsTopLevel}}{{else}}{{.Prefix}}{{.Alias}}/{{end}}" class="h-card p-author u-url" rel="me author">{{.DisplayTitle}}</a></h1> {{if .Description}}<p class="description p-note">{{.Description}}</p>{{end}} {{/*if not .Public/*}} <!--p class="meta-note"><span>Private collection</span>. Only you can see this page.</p--> {{/*end*/}} {{if .PinnedPosts}}<nav class="pinned-posts"> - {{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}</nav> + {{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL .Hostname}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}</nav> {{end}} </header> {{if .Posts}}<section id="wrapper" itemscope itemtype="http://schema.org/Blog">{{else}}<div id="wrapper">{{end}} {{if .IsWelcome}} <div id="welcome"> <h2>Welcome, <strong>{{.Username}}</strong>!</h2> <p>This is your new blog.</p> <p><a class="simple-cta" href="/#{{.Alias}}">Start writing</a>, or <a class="simple-cta" href="/me/c/{{.Alias}}">customize</a> your blog.</p> <p>Check out our <a class="simple-cta" href="https://guides.write.as/writing/?pk_campaign=welcome">writing guide</a> to see what else you can do, and <a class="simple-cta" href="/contact">get in touch</a> anytime with questions or feedback.</p> </div> {{end}} {{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 .ShowFooterBranding }} <footer> <hr /> <nav dir="ltr"> {{if not .SingleUser}}<a class="home pubd" href="/">{{.SiteName}}</a> · {{end}}powered by <a style="margin-left:0" href="https://writefreely.org">writefreely</a> </nav> </footer> {{ end }} </body> {{if .CanShowScript}} {{range .ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}} {{if .Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}} {{end}} <script src="/js/h.js"></script> <script src="/js/postactions.js"></script> <script type="text/javascript"> var deleting = false; function delPost(e, id, owned) { e.preventDefault(); if (deleting) { return; } // TODO: UNDO! if (window.confirm('Are you sure you want to delete this post?')) { // AJAX deletePost(id, "", function() { // Remove post from list var $postEl = document.getElementById('post-' + id); $postEl.parentNode.removeChild($postEl); // TODO: add next post from this collection at the bottom }); } } var deletePost = function(postID, token, callback) { deleting = true; var $delBtn = document.getElementById('post-' + postID).getElementsByClassName('delete action')[0]; $delBtn.innerHTML = '...'; var http = new XMLHttpRequest(); var url = "/api/posts/" + postID; http.open("DELETE", url, true); http.onreadystatechange = function() { if (http.readyState == 4) { deleting = false; if (http.status == 204) { callback(); } else if (http.status == 409) { $delBtn.innerHTML = 'delete'; alert("Post is synced to another account. Delete the post from that account instead."); // TODO: show "remove" button instead of "delete" now // Persist that state. // Have it remove the post locally only. } else { $delBtn.innerHTML = 'delete'; alert("Failed to delete." + (http.status>=500?" Please try again.":"")); } } } http.send(); }; var pinning = false; function pinPost(e, postID, slug, title) { e.preventDefault(); if (pinning) { return; } pinning = true; var callback = function() { // Visibly remove post from collection var $postEl = document.getElementById('post-' + postID); $postEl.parentNode.removeChild($postEl); var $header = document.querySelector('header:not(.multiuser)'); var $pinnedNavs = $header.getElementsByTagName('nav'); // Add link to nav var link = '<a class="pinned" href="/{{.Alias}}/'+slug+'">'+title+'</a>'; if ($pinnedNavs.length == 0) { $header.insertAdjacentHTML("beforeend", '<nav>'+link+'</nav>'); } else { $pinnedNavs[0].insertAdjacentHTML("beforeend", link); } }; var $pinBtn = document.getElementById('post-' + postID).getElementsByClassName('pin action')[0]; $pinBtn.innerHTML = '...'; var http = new XMLHttpRequest(); var url = "/api/collections/{{.Alias}}/pin"; var params = [ { "id": postID } ]; http.open("POST", url, true); http.setRequestHeader("Content-type", "application/json"); http.onreadystatechange = function() { if (http.readyState == 4) { pinning = false; if (http.status == 200) { callback(); } else if (http.status == 409) { $pinBtn.innerHTML = 'pin'; alert("Post is synced to another account. Delete the post from that account instead."); // TODO: show "remove" button instead of "delete" now // Persist that state. // Have it remove the post locally only. } else { $pinBtn.innerHTML = 'pin'; alert("Failed to pin." + (http.status>=500?" Please try again.":"")); } } } http.send(JSON.stringify(params)); }; try { WebFontConfig = { custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] } }; (function() { var wf = document.createElement('script'); wf.src = '/js/webfont.js'; wf.type = 'text/javascript'; wf.async = 'true'; var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(wf, s); })(); } catch (e) {} </script> </html>{{end}} diff --git a/templates/collection-post.tmpl b/templates/collection-post.tmpl index 7075226..a4084b3 100644 --- a/templates/collection-post.tmpl +++ b/templates/collection-post.tmpl @@ -1,130 +1,130 @@ {{define "post"}}<!DOCTYPE HTML> <html {{if .Language.Valid}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}"> <head prefix="og: http://ogp.me/ns# article: http://ogp.me/ns/article#"> <meta charset="utf-8"> <title>{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}}</title> <link rel="stylesheet" type="text/css" href="/css/write.css" /> <link rel="shortcut icon" href="/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> {{ if .IsFound }} - <link rel="canonical" href="{{.CanonicalURL}}" /> + <link rel="canonical" href="{{.CanonicalURL .Hostname}}" /> <meta name="generator" content="WriteFreely"> <meta name="title" content="{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{if .Collection.Title}}{{.Collection.Title}}{{else}}{{.Collection.Alias}}{{end}}"> <meta name="description" content="{{.Summary}}"> {{if gt .Views 1}}<meta name="twitter:label1" value="Views"> <meta name="twitter:data1" value="{{largeNumFmt .Views}}">{{end}} <meta name="author" content="{{.Collection.Title}}" /> <meta itemprop="description" content="{{.Summary}}"> <meta itemprop="datePublished" content="{{.CreatedDate}}" /> <meta name="twitter:card" content="summary"> <meta name="twitter:description" content="{{.Summary}}"> <meta name="twitter:title" content="{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{if .Collection.Title}}{{.Collection.Title}}{{else}}{{.Collection.Alias}}{{end}}"> {{if gt (len .Images) 0}}<meta name="twitter:image" content="{{index .Images 0}}">{{else}}<meta name="twitter:image" content="{{.Collection.AvatarURL}}">{{end}} <meta property="og:title" content="{{.PlainDisplayTitle}}" /> <meta property="og:description" content="{{.Summary}}" /> <meta property="og:site_name" content="{{.Collection.DisplayTitle}}" /> <meta property="og:type" content="article" /> - <meta property="og:url" content="{{.CanonicalURL}}" /> + <meta property="og:url" content="{{.CanonicalURL .Hostname}}" /> <meta property="og:updated_time" content="{{.Created8601}}" /> {{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Collection.AvatarURL}}">{{end}} <meta property="article:published_time" content="{{.Created8601}}"> {{ end }} {{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}} {{if .Collection.RenderMathJax}} <!-- Add mathjax logic --> {{template "mathjax" . }} {{end}} <!-- Add highlighting logic --> {{template "highlighting" .}} </head> <body id="post"> <div id="overlay"></div> <header> <h1 dir="{{.Direction}}" id="blog-title"><a rel="author" href="{{if .IsTopLevel}}/{{else}}/{{.Collection.Alias}}/{{end}}" class="h-card p-author">{{.Collection.DisplayTitle}}</a></h1> <nav> {{if .PinnedPosts}} - {{range .PinnedPosts}}<a class="pinned{{if eq .Slug.String $.Slug.String}} selected{{end}}" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}} + {{range .PinnedPosts}}<a class="pinned{{if eq .Slug.String $.Slug.String}} selected{{end}}" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL .Hostname}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}} {{end}} {{ if and .IsOwner .IsFound }}<span class="views" dir="ltr"><strong>{{largeNumFmt .Views}}</strong> {{pluralize "view" "views" .Views}}</span> <a class="xtra-feature" href="/{{if not .SingleUser}}{{.Collection.Alias}}/{{end}}{{.Slug.String}}/edit" dir="{{.Direction}}">Edit</a> {{if .IsPinned}}<a class="xtra-feature unpin" href="/{{.Collection.Alias}}/{{.Slug.String}}/unpin" dir="{{.Direction}}" onclick="unpinPost(event, '{{.ID}}')">Unpin</a>{{end}} {{ end }} </nav> </header> <article id="post-body" class="{{.Font}} h-entry {{if not .IsFound}}error-page{{end}}">{{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}{{if .Title.String}}<h2 id="title" class="p-name">{{.FormattedDisplayTitle}}</h2>{{end}}<div class="e-content">{{.HTMLContent}}</div></article> {{ if .Collection.ShowFooterBranding }} <footer dir="ltr"><hr><nav><p style="font-size: 0.9em">{{localhtml "published with write.as" .Language.String}}</p></nav></footer> {{ end }} </body> {{if .Collection.CanShowScript}} {{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}} {{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}} {{end}} <script type="text/javascript"> var pinning = false; function unpinPost(e, postID) { e.preventDefault(); if (pinning) { return; } pinning = true; var $header = document.getElementsByTagName('header')[0]; var callback = function() { // Hide current page var $pinnedNavLink = $header.getElementsByTagName('nav')[0].querySelector('.pinned.selected'); $pinnedNavLink.style.display = 'none'; }; var $pinBtn = $header.getElementsByClassName('unpin')[0]; $pinBtn.innerHTML = '...'; var http = new XMLHttpRequest(); var url = "/api/collections/{{.Collection.Alias}}/unpin"; var params = [ { "id": postID } ]; http.open("POST", url, true); http.setRequestHeader("Content-type", "application/json"); http.onreadystatechange = function() { if (http.readyState == 4) { pinning = false; if (http.status == 200) { callback(); $pinBtn.style.display = 'none'; $pinBtn.innerHTML = 'Pin'; } else if (http.status == 409) { $pinBtn.innerHTML = 'Unpin'; } else { $pinBtn.innerHTML = 'Unpin'; alert("Failed to unpin." + (http.status>=500?" Please try again.":"")); } } } http.send(JSON.stringify(params)); }; try { // Fonts WebFontConfig = { custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] } }; (function() { var wf = document.createElement('script'); wf.src = '/js/webfont.js'; wf.type = 'text/javascript'; wf.async = 'true'; var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(wf, s); })(); } catch (e) { /* ¯\_(ツ)_/¯ */ } </script> </html>{{end}} diff --git a/templates/collection-tags.tmpl b/templates/collection-tags.tmpl index 7cad3b7..039e071 100644 --- a/templates/collection-tags.tmpl +++ b/templates/collection-tags.tmpl @@ -1,194 +1,194 @@ {{define "collection-tags"}}<!DOCTYPE HTML> <html> <head prefix="og: http://ogp.me/ns# article: http://ogp.me/ns/article#"> <meta charset="utf-8"> <title>{{.Tag}} — {{.Collection.DisplayTitle}}</title> <link rel="stylesheet" type="text/css" href="/css/write.css" /> <link rel="shortcut icon" href="/favicon.ico" /> {{if not .Collection.IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.Tag}} posts on {{.DisplayTitle}}" href="{{.CanonicalURL}}tag:{{.Tag}}/feed/" />{{end}} <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="canonical" href="{{.CanonicalURL}}tag:{{.Tag | tolower}}" /> <meta name="generator" content="Write.as"> <meta name="title" content="{{.Tag}} — {{.Collection.DisplayTitle}}"> <meta name="description" content="{{.Tag}} posts on {{.Collection.DisplayTitle}}"> <meta name="application-name" content="Write.as"> <meta name="application-url" content="https://write.as"> {{if gt .Views 1}}<meta name="twitter:label1" value="Views"> <meta name="twitter:data1" value="{{largeNumFmt .Views}}">{{end}} <meta itemprop="name" content="{{.Collection.DisplayTitle}}"> <meta itemprop="description" content="{{.Tag}} posts on {{.Collection.DisplayTitle}}"> <meta name="twitter:card" content="summary"> <meta name="twitter:site" content="@writeas__"> <meta name="twitter:description" content="{{.Tag}} posts on {{.Collection.DisplayTitle}}"> <meta name="twitter:title" content="{{.Tag}} — {{.Collection.DisplayTitle}}"> <meta name="twitter:image" content="{{.Collection.AvatarURL}}"> <meta property="og:title" content="{{.Tag}} — {{.Collection.DisplayTitle}}" /> <meta property="og:site_name" content="{{.DisplayTitle}}" /> <meta property="og:type" content="article" /> <meta property="og:url" content="{{.CanonicalURL}}tag:{{.Tag}}" /> <meta property="og:image" content="{{.Collection.AvatarURL}}"> {{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}} {{if .Collection.RenderMathJax}} <!-- Add mathjax logic --> {{template "mathjax" .}} {{end}} <!-- Add highlighting logic --> {{template "highlighting" . }} </head> <body id="subpage"> <div id="overlay"></div> <header> <h1 dir="{{.Direction}}" id="blog-title"><a href="{{if .IsTopLevel}}/{{else}}/{{.Collection.Alias}}/{{end}}" class="h-card p-author">{{.Collection.DisplayTitle}}</a></h1> <nav> {{if .PinnedPosts}} - {{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL}}{{end}}">{{.DisplayTitle}}</a>{{end}} + {{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL .Hostname}}{{end}}">{{.DisplayTitle}}</a>{{end}} {{end}} </nav> </header> {{if .Posts}}<section id="wrapper" itemscope itemtype="http://schema.org/Blog">{{else}}<div id="wrapper">{{end}} <h1>{{.Tag}}</h1> {{template "posts" .}} {{if .Posts}}</section>{{else}}</div>{{end}} {{ if .Collection.ShowFooterBranding }} <footer dir="ltr"> <hr> <nav> <p style="font-size: 0.9em"><a class="home pubd" href="/">{{.SiteName}}</a> · powered by <a style="margin-left:0" href="https://writefreely.org">writefreely</a></p> </nav> </footer> {{ end }} </body> {{if .CanShowScript}} {{range .ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}} {{if .Collection.Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}} {{end}} {{if .IsOwner}} <script src="/js/h.js"></script> <script src="/js/postactions.js"></script> {{end}} <script type="text/javascript"> {{if .IsOwner}} var deleting = false; function delPost(e, id, owned) { e.preventDefault(); if (deleting) { return; } // TODO: UNDO! if (window.confirm('Are you sure you want to delete this post?')) { // AJAX deletePost(id, "", function() { // Remove post from list var $postEl = document.getElementById('post-' + id); $postEl.parentNode.removeChild($postEl); // TODO: add next post from this collection at the bottom }); } } var deletePost = function(postID, token, callback) { deleting = true; var $delBtn = document.getElementById('post-' + postID).getElementsByClassName('delete action')[0]; $delBtn.innerHTML = '...'; var http = new XMLHttpRequest(); var url = "/api/posts/" + postID; http.open("DELETE", url, true); http.onreadystatechange = function() { if (http.readyState == 4) { deleting = false; if (http.status == 204) { callback(); } else if (http.status == 409) { $delBtn.innerHTML = 'delete'; alert("Post is synced to another account. Delete the post from that account instead."); // TODO: show "remove" button instead of "delete" now // Persist that state. // Have it remove the post locally only. } else { $delBtn.innerHTML = 'delete'; alert("Failed to delete." + (http.status>=500?" Please try again.":"")); } } } http.send(); }; var pinning = false; function pinPost(e, postID, slug, title) { e.preventDefault(); if (pinning) { return; } pinning = true; var callback = function() { // Visibly remove post from collection var $postEl = document.getElementById('post-' + postID); $postEl.parentNode.removeChild($postEl); var $header = document.getElementsByTagName('header')[0]; var $pinnedNavs = $header.getElementsByTagName('nav'); // Add link to nav var link = '<a class="pinned" href="{{if not .SingleUser}}/{{.Alias}}/{{end}}'+slug+'">'+title+'</a>'; if ($pinnedNavs.length == 0) { $header.insertAdjacentHTML("beforeend", '<nav>'+link+'</nav>'); } else { $pinnedNavs[0].insertAdjacentHTML("beforeend", link); } }; var $pinBtn = document.getElementById('post-' + postID).getElementsByClassName('pin action')[0]; $pinBtn.innerHTML = '...'; var http = new XMLHttpRequest(); var url = "/api/collections/{{.Alias}}/pin"; var params = [ { "id": postID } ]; http.open("POST", url, true); http.setRequestHeader("Content-type", "application/json"); http.onreadystatechange = function() { if (http.readyState == 4) { pinning = false; if (http.status == 200) { callback(); } else if (http.status == 409) { $pinBtn.innerHTML = 'pin'; alert("Post is synced to another account. Delete the post from that account instead."); // TODO: show "remove" button instead of "delete" now // Persist that state. // Have it remove the post locally only. } else { $pinBtn.innerHTML = 'pin'; alert("Failed to pin." + (http.status>=500?" Please try again.":"")); } } } http.send(JSON.stringify(params)); }; {{end}} try { // Fonts WebFontConfig = { custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] } }; (function() { var wf = document.createElement('script'); wf.src = '/js/webfont.js'; wf.type = 'text/javascript'; wf.async = 'true'; var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(wf, s); })(); } catch (e) { /* ¯\_(ツ)_/¯ */ } </script> </html>{{end}} diff --git a/templates/collection.tmpl b/templates/collection.tmpl index 36a266b..fa66f69 100644 --- a/templates/collection.tmpl +++ b/templates/collection.tmpl @@ -1,230 +1,230 @@ {{define "collection"}}<!DOCTYPE HTML> <html {{if .Language}}lang="{{.Language}}"{{end}} dir="{{.Direction}}"> <head> <meta charset="utf-8"> <title>{{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}}</title> <link rel="stylesheet" type="text/css" href="/css/write.css" /> <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 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" /> <meta name="generator" content="WriteFreely"> <meta name="description" content="{{.Description}}"> <meta itemprop="name" content="{{.DisplayTitle}}"> <meta itemprop="description" content="{{.Description}}"> <meta name="twitter:card" content="summary"> <meta name="twitter:title" content="{{.DisplayTitle}}"> <meta name="twitter:image" content="{{.AvatarURL}}"> <meta name="twitter:description" content="{{.Description}}"> <meta property="og:title" content="{{.DisplayTitle}}" /> <meta property="og:site_name" content="{{.DisplayTitle}}" /> <meta property="og:type" content="article" /> <meta property="og:url" content="{{.CanonicalURL}}" /> <meta property="og:description" content="{{.Description}}" /> <meta property="og:image" content="{{.AvatarURL}}"> {{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}} {{if .RenderMathJax}} <!-- Add mathjax logic --> {{template "mathjax" .}} {{end}} <!-- Add highlighting logic --> {{template "highlighting" . }} </head> <body id="collection" itemscope itemtype="http://schema.org/WebPage"> {{if or .IsOwner .SingleUser}}<nav id="manage"><ul> <li><a onclick="void(0)">☰ Menu</a> <ul> {{ if .IsOwner }} {{if .SingleUser}} <li><a href="/me/new">New Post</a></li> {{else}} <li><a href="/#{{.Alias}}" class="write">{{.SiteName}}</a></li> {{end}} {{if .SimpleNav}}<li><a href="/new#{{.Alias}}">New Post</a></li>{{end}} <li><a href="/me/c/{{.Alias}}">Customize</a></li> <li><a href="/me/c/{{.Alias}}/stats">Stats</a></li> <li class="separator"><hr /></li> {{if not .SingleUser}}<li><a href="/me/c/"><img class="ic-18dp" src="/img/ic_blogs_dark@2x.png" /> View Blogs</a></li>{{end}} <li><a href="/me/posts/"><img class="ic-18dp" src="/img/ic_list_dark@2x.png" /> View Drafts</a></li> {{ else }} <li><a href="/login">Log in</a></li> {{ end }} </ul> </li> </ul></nav>{{end}} <header> <h1 dir="{{.Direction}}" id="blog-title">{{if .Posts}}{{else}}<span class="writeas-prefix"><a href="/">write.as</a></span> {{end}}<a href="/{{if .IsTopLevel}}{{else}}{{.Prefix}}{{.Alias}}/{{end}}" class="h-card p-author u-url" rel="me author">{{.DisplayTitle}}</a></h1> {{if .Description}}<p class="description p-note">{{.Description}}</p>{{end}} {{/*if not .Public/*}} <!--p class="meta-note"><span>Private collection</span>. Only you can see this page.</p--> {{/*end*/}} {{if .PinnedPosts}}<nav> - {{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}</nav> + {{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL .Hostname}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}</nav> {{end}} </header> {{if .Posts}}<section id="wrapper" itemscope itemtype="http://schema.org/Blog">{{else}}<div id="wrapper">{{end}} {{if .IsWelcome}} <div id="welcome"> <h2>Welcome, <strong>{{.Username}}</strong>!</h2> <p>This is your new blog.</p> <p><a class="simple-cta" href="/#{{.Alias}}">Start writing</a>, or <a class="simple-cta" href="/me/c/{{.Alias}}">customize</a> your blog.</p> <p>Check out our <a class="simple-cta" href="https://guides.write.as/writing/?pk_campaign=welcome">writing guide</a> to see what else you can do, and <a class="simple-cta" href="/contact">get in touch</a> anytime with questions or feedback.</p> </div> {{end}} {{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 .ShowFooterBranding }} <footer> <hr /> <nav dir="ltr"> {{if not .SingleUser}}<a class="home pubd" href="/">{{.SiteName}}</a> · {{end}}powered by <a style="margin-left:0" href="https://writefreely.org">writefreely</a> </nav> </footer> {{ end }} </body> {{if .CanShowScript}} {{range .ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}} {{if .Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}} {{end}} <script src="/js/h.js"></script> <script src="/js/postactions.js"></script> <script type="text/javascript"> var deleting = false; function delPost(e, id, owned) { e.preventDefault(); if (deleting) { return; } // TODO: UNDO! if (window.confirm('Are you sure you want to delete this post?')) { // AJAX deletePost(id, "", function() { // Remove post from list var $postEl = document.getElementById('post-' + id); $postEl.parentNode.removeChild($postEl); // TODO: add next post from this collection at the bottom }); } } var deletePost = function(postID, token, callback) { deleting = true; var $delBtn = document.getElementById('post-' + postID).getElementsByClassName('delete action')[0]; $delBtn.innerHTML = '...'; var http = new XMLHttpRequest(); var url = "/api/posts/" + postID; http.open("DELETE", url, true); http.onreadystatechange = function() { if (http.readyState == 4) { deleting = false; if (http.status == 204) { callback(); } else if (http.status == 409) { $delBtn.innerHTML = 'delete'; alert("Post is synced to another account. Delete the post from that account instead."); // TODO: show "remove" button instead of "delete" now // Persist that state. // Have it remove the post locally only. } else { $delBtn.innerHTML = 'delete'; alert("Failed to delete." + (http.status>=500?" Please try again.":"")); } } } http.send(); }; var pinning = false; function pinPost(e, postID, slug, title) { e.preventDefault(); if (pinning) { return; } pinning = true; var callback = function() { // Visibly remove post from collection var $postEl = document.getElementById('post-' + postID); $postEl.parentNode.removeChild($postEl); var $header = document.getElementsByTagName('header')[0]; var $pinnedNavs = $header.getElementsByTagName('nav'); // Add link to nav var link = '<a class="pinned" href="{{if not .SingleUser}}/{{.Alias}}/{{end}}'+slug+'">'+title+'</a>'; if ($pinnedNavs.length == 0) { $header.insertAdjacentHTML("beforeend", '<nav>'+link+'</nav>'); } else { $pinnedNavs[0].insertAdjacentHTML("beforeend", link); } }; var $pinBtn = document.getElementById('post-' + postID).getElementsByClassName('pin action')[0]; $pinBtn.innerHTML = '...'; var http = new XMLHttpRequest(); var url = "/api/collections/{{.Alias}}/pin"; var params = [ { "id": postID } ]; http.open("POST", url, true); http.setRequestHeader("Content-type", "application/json"); http.onreadystatechange = function() { if (http.readyState == 4) { pinning = false; if (http.status == 200) { callback(); } else if (http.status == 409) { $pinBtn.innerHTML = 'pin'; alert("Post is synced to another account. Delete the post from that account instead."); // TODO: show "remove" button instead of "delete" now // Persist that state. // Have it remove the post locally only. } else { $pinBtn.innerHTML = 'pin'; alert("Failed to pin." + (http.status>=500?" Please try again.":"")); } } } http.send(JSON.stringify(params)); }; try { WebFontConfig = { custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] } }; (function() { var wf = document.createElement('script'); wf.src = '/js/webfont.js'; wf.type = 'text/javascript'; wf.async = 'true'; var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(wf, s); })(); } catch (e) {} </script> </html>{{end}} diff --git a/templates/read.tmpl b/templates/read.tmpl index 9541ab5..91fbeb4 100644 --- a/templates/read.tmpl +++ b/templates/read.tmpl @@ -1,132 +1,132 @@ {{define "head"}}<title>{{.SiteName}} Reader</title> <link rel="alternate" type="application/rss+xml" title="{{.SiteName}} Reader" href="/read/feed/" /> {{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .CurrentPage}}">{{end}} {{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .CurrentPage}}">{{end}} <meta name="description" content="Read the latest posts from {{.SiteName}}."> <meta itemprop="name" content="{{.SiteName}} Reader"> <meta itemprop="description" content="Read the latest posts from {{.SiteName}}."> <meta name="twitter:card" content="summary_large_image"> <meta name="twitter:title" content="{{.SiteName}} Reader"> <meta name="twitter:description" content="Read the latest posts from {{.SiteName}}."> <meta property="og:title" content="{{.SiteName}} Reader" /> <meta property="og:type" content="object" /> <meta property="og:description" content="Read the latest posts from {{.SiteName}}." /> <style> .heading h1 { font-weight: 300; text-align: center; margin: 3em 0 0; } .heading p { text-align: center; margin: 1.5em 0 4.5em; font-size: 1.1em; color: #777; } #wrapper { font-size: 1.2em; } .preview { max-height: 180px; overflow: hidden; position: relative; } .preview .over { position: absolute; top: 5em; bottom: 0; left: 0; right: 0; /* Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#ffffff+0,ffffff+100&0+0,1+100 */ background: -moz-linear-gradient(top, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 100%); /* FF3.6-15 */ background: -webkit-linear-gradient(top, rgba(255,255,255,0) 0%,rgba(255,255,255,1) 100%); /* Chrome10-25,Safari5.1-6 */ background: linear-gradient(to bottom, rgba(255,255,255,0) 0%,rgba(255,255,255,1) 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#00ffffff', endColorstr='#ffffff',GradientType=0 ); /* IE6-9 */ } p.source { font-size: 0.86em; margin-top: 0.25em; margin-bottom: 0; } .attention-box { text-align: center; font-size: 1.1em; } .attention-box hr { margin: 4rem auto; } hr { max-width: 40rem; } header { padding: 0 !important; text-align: left !important; margin: 1em !important; max-width: 100% !important; } body#collection header nav { display: inline !important; } body#collection header nav:not(#full-nav):not(#user-nav) { margin: 0 0 0 1em !important; } header nav#user-nav { margin-left: 0 !important; } body#collection header nav.tabs a:first-child { margin-left: 1em; } </style> {{end}} {{define "body-attrs"}}id="collection"{{end}} {{define "content"}} <div class="content-container snug" style="max-width: 40rem;"> <h1>{{.ContentTitle}}</h1> <p{{if .SelTopic}} style="text-align:center"{{end}}>{{if .SelTopic}}#{{.SelTopic}} posts{{else}}{{.Content}}{{end}}</p> </div> <div id="wrapper"> {{ if gt (len .Posts) 0 }} <section itemscope itemtype="http://schema.org/Blog"> {{range .Posts}}<article class="{{.Font}} h-entry" itemscope itemtype="http://schema.org/BlogPosting"> - {{if .Title.String}}<h2 class="post-title" itemprop="name" class="p-name"><a href="{{if .Slug.String}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL}}.md{{end}}" itemprop="url" class="u-url">{{.PlainDisplayTitle}}</a></h2> + {{if .Title.String}}<h2 class="post-title" itemprop="name" class="p-name"><a href="{{if .Slug.String}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Hostname}}.md{{end}}" itemprop="url" class="u-url">{{.PlainDisplayTitle}}</a></h2> <time class="dt-published" datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}">{{if not .Title.String}}<a href="{{.Collection.CanonicalURL}}{{.Slug.String}}" itemprop="url">{{end}}{{.DisplayDate}}{{if not .Title.String}}</a>{{end}}</time> {{else}} - <h2 class="post-title" itemprop="name"><time class="dt-published" datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}"><a href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL}}.md{{end}}" itemprop="url" class="u-url">{{.DisplayDate}}</a></time></h2> + <h2 class="post-title" itemprop="name"><time class="dt-published" datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}"><a href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Hostname}}.md{{end}}" itemprop="url" class="u-url">{{.DisplayDate}}</a></time></h2> {{end}} <p class="source">{{if .Collection}}from <a href="{{.Collection.CanonicalURL}}">{{.Collection.DisplayTitle}}</a>{{else}}<em>Anonymous</em>{{end}}</p> {{if .Excerpt}}<div class="p-summary" {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">{{.Excerpt}}</div> - <a class="read-more" href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL}}.md{{end}}">{{localstr "Read more..." .Language.String}}</a>{{else}}<div class="e-content preview" {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">{{ if not .HTMLContent }}<p id="post-body" class="e-content preview">{{.Content}}</p>{{ else }}{{.HTMLContent}}{{ end }}<div class="over"> </div></div> + <a class="read-more" href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Hostname}}.md{{end}}">{{localstr "Read more..." .Language.String}}</a>{{else}}<div class="e-content preview" {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">{{ if not .HTMLContent }}<p id="post-body" class="e-content preview">{{.Content}}</p>{{ else }}{{.HTMLContent}}{{ end }}<div class="over"> </div></div> - <a class="read-more maybe" href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL}}.md{{end}}">{{localstr "Read more..." .Language.String}}</a>{{end}}</article> + <a class="read-more maybe" href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Hostname}}.md{{end}}">{{localstr "Read more..." .Language.String}}</a>{{end}}</article> {{end}} </section> {{ else }} <div class="attention-box"> <p>No posts here yet!</p> </div> {{ end }} {{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix"> {{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .CurrentPage}}">⇠ Older</a>{{end}} {{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .CurrentPage}}">Newer ⇢</a>{{end}} </nav>{{end}} </div> <script type="text/javascript"> (function() { var $articles = document.querySelectorAll('article'); for (var i=0; i<$articles.length; i++) { var $art = $articles[i]; var $more = $art.querySelector('.read-more.maybe'); if ($more != null) { if ($art.querySelector('.e-content.preview').clientHeight < 180) { $more.parentNode.removeChild($more); var $overlay = $art.querySelector('.over'); $overlay.parentNode.removeChild($overlay); } } } })(); </script> {{end}}