diff --git a/README.md b/README.md index d9a2d69..8a9579a 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,90 @@

We received a request to reset your password on %s. Please click the following link to continue:
%s
You're subscribed to email updates.
`, -1) + p.Content = strings.Replace(p.Content, shortCodeEmailSub, `You're subscribed to email updates.
`, -1) if p.HTMLContent == template.HTML("") { p.formatContent(app.cfg, false, false) } p.augmentReadingDestination() title := p.Title.String if title != "" { title = p.Title.String + "\n\n" } plainMsg := title + "A new post from " + p.CanonicalURL(app.cfg.App.Host) + "\n\n" + stripmd.Strip(p.Content) plainMsg += ` --------------------------------------------------------------------------------- Originally published on ` + p.Collection.DisplayTitle() + ` (` + p.Collection.CanonicalURL() + `), a blog you subscribe to. Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%` mlr, err := mailer.New(app.cfg.Email) if err != nil { return err } m, err := mlr.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg) if err != nil { return err } replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo) if replyTo != "" { m.SetReplyTo(replyTo) } subs, err := app.db.GetEmailSubscribers(collID, true) if err != nil { log.Error("Unable to get email subscribers: %v", err) return err } if len(subs) == 0 { return nil } if title != "" { title = string(`Confirm your subscription to ` + c.DisplayTitle() + ` to start receiving future posts:
Subscribe to ` + c.DisplayTitle() + `
If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.
Registration is currently closed.
You can always sign up on another instance.
{{ end }}Password login is disabled on this server, so it's not possible to reset your password.
Email is not configured on this server! Please contact your admin to reset your password.
{{.Error}}
{{ else }} {{if .Flashes}}(.+)
") mentionReg = regexp.MustCompile(`@([A-Za-z0-9._%+-]+)(@[A-Za-z0-9.-]+\.[A-Za-z]+)\b`) ) func (p *Post) handlePremiumContent(c *Collection, isOwner, postPage bool, cfg *config.Config) { if c.Monetization != "" { // User has Web Monetization enabled, so split content if it exists spl := strings.Index(p.Content, shortCodePaid) p.IsPaid = spl > -1 if postPage { // We're viewing the individual post if isOwner { p.Content = strings.Replace(p.Content, shortCodePaid, "\n\n"+`Your subscriber content begins here.
`+"\n\n", 1) } else { if spl > -1 { p.Content = p.Content[:spl+len(shortCodePaid)] p.Content = strings.Replace(p.Content, shortCodePaid, "\n\n"+`Continue reading with a Coil membership.
`+"\n\n", 1) } } } else { // We've viewing the post on the collection landing if spl > -1 { baseURL := c.CanonicalURL() if isOwner { baseURL = "/" + c.Alias + "/" } p.Content = p.Content[:spl+len(shortCodePaid)] p.HTMLExcerpt = template.HTML(applyMarkdown([]byte(p.Content[:spl]), baseURL, cfg)) } } } } func (p *Post) formatContent(cfg *config.Config, c *Collection, isOwner bool, isPostPage bool) { baseURL := c.CanonicalURL() // TODO: redundant if !isSingleUser { baseURL = "/" + c.Alias + "/" } p.handlePremiumContent(c, isOwner, isPostPage, cfg) p.Content = strings.Replace(p.Content, "<!--paid-->", "", 1) p.HTMLTitle = template.HTML(applyBasicMarkdown([]byte(p.Title.String))) p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), baseURL, cfg)) if exc := strings.Index(string(p.Content), ""); exc > -1 { p.HTMLExcerpt = template.HTML(applyMarkdown([]byte(p.Content[:exc]), baseURL, cfg)) } } func (p *PublicPost) formatContent(cfg *config.Config, isOwner bool, isPostPage bool) { p.Post.formatContent(cfg, &p.Collection.Collection, isOwner, isPostPage) } func (p *Post) augmentContent(c *Collection) { if p.PinnedPosition.Valid { // Don't augment posts that are pinned return } if strings.Index(p.Content, shortCodeNoSig) > -1 { // Don't augment posts with the special "nosig" shortcode return } // Add post signatures if c.Signature != "" { p.Content += "\n\n" + c.Signature } } func (p *PublicPost) augmentContent() { p.Post.augmentContent(&p.Collection.Collection) } func (p *PublicPost) augmentReadingDestination() { if p.IsPaid { p.HTMLContent += template.HTML("\n\n" + `` + localStr("Read more...", p.Language.String) + ` ($)
`) } } func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string { return applyMarkdownSpecial(data, baseURL, cfg, cfg.App.SingleUser) } func disableYoutubeAutoplay(outHTML string) string { for _, match := range youtubeReg.FindAllString(outHTML, -1) { u, err := url.Parse(match) if err != nil { continue } u.RawQuery = html.UnescapeString(u.RawQuery) q := u.Query() // Set Youtube autoplay url parameter, if any, to 0 if len(q["autoplay"]) == 1 { q.Set("autoplay", "0") } u.RawQuery = q.Encode() cleanURL := u.String() outHTML = strings.Replace(outHTML, match, cleanURL, 1) } return outHTML } func applyMarkdownSpecial(data []byte, baseURL string, cfg *config.Config, skipNoFollow bool) string { mdExtensions := 0 | blackfriday.EXTENSION_TABLES | blackfriday.EXTENSION_FENCED_CODE | blackfriday.EXTENSION_AUTOLINK | blackfriday.EXTENSION_STRIKETHROUGH | blackfriday.EXTENSION_SPACE_HEADERS | blackfriday.EXTENSION_AUTO_HEADER_IDS htmlFlags := 0 | blackfriday.HTML_USE_SMARTYPANTS | blackfriday.HTML_SMARTYPANTS_DASHES if baseURL != "" { htmlFlags |= blackfriday.HTML_HASHTAGS } // Generate Markdown md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions) if baseURL != "" { // Replace special text generated by Markdown parser tagPrefix := baseURL + "tag:" if cfg.App.Chorus { tagPrefix = "/read/t/" } md = []byte(hashtagReg.ReplaceAll(md, []byte("#$1"))) handlePrefix := cfg.App.Host + "/@/" md = []byte(mentionReg.ReplaceAll(md, []byte("@$1$2"))) } // Strip out bad HTML policy := getSanitizationPolicy() policy.RequireNoFollowOnLinks(!skipNoFollow) outHTML := string(policy.SanitizeBytes(md)) // Strip newlines on certain block elements that render with them outHTML = blockReg.ReplaceAllString(outHTML, "<$1>") outHTML = endBlockReg.ReplaceAllString(outHTML, "$1>$2>") outHTML = disableYoutubeAutoplay(outHTML) return outHTML } func applyBasicMarkdown(data []byte) string { if len(bytes.TrimSpace(data)) == 0 { return "" } mdExtensions := 0 | blackfriday.EXTENSION_STRIKETHROUGH | blackfriday.EXTENSION_SPACE_HEADERS | blackfriday.EXTENSION_HEADER_IDS htmlFlags := 0 | blackfriday.HTML_SKIP_HTML | blackfriday.HTML_USE_SMARTYPANTS | blackfriday.HTML_SMARTYPANTS_DASHES // Generate Markdown // This passes the supplied title into blackfriday.Markdown() as an H1 header, so we only render HTML that // belongs in an H1. md := blackfriday.Markdown(append([]byte("# "), data...), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions) // Remove H1 markup md = bytes.TrimSpace(md) // blackfriday.Markdown adds a newline at the end of theThis page is missing.
Are you sure it was ever here?` + shortCodeNoSig, } pp := po.processPost() p = &pp } else { return err } } // Check if the authenticated user is the post owner p.IsOwner = u != nil && u.ID == p.OwnerID.Int64 p.Collection = coll p.IsTopLevel = app.cfg.App.SingleUser // Only allow a post owner or admin to view a post for silenced collections if silenced && !p.IsOwner && (u == nil || !u.IsAdmin()) { return ErrPostNotFound } // Check if post has been unpublished if p.Content == "" && p.Title.String == "" { return impart.HTTPError{http.StatusGone, "Post was unpublished."} } p.augmentContent() // 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 IsActivityPubRequest(r) { if !postFound { return ErrCollectionPageNotFound } p.extractData() ap := p.ActivityObject(app) ap.Context = []interface{}{activitystreams.Namespace} setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, ap, http.StatusOK) } else { p.extractData() p.Content = strings.Replace(p.Content, "", "", 1) if app.cfg.Email.Enabled() && c.EmailSubsEnabled() { // TODO: indicate plan is inactive or subs disabled when OWNER is viewing their own post. if u != nil && u.IsEmailSubscriber(app, c.ID) { - p.Content = strings.Replace(p.Content, "", `You're subscribed to email updates. Unsubscribe.
`, -1) + p.Content = strings.Replace(p.Content, shortCodeEmailSub, `You're subscribed to email updates. Unsubscribe.
`, -1) } else { - p.Content = strings.Replace(p.Content, "", ``, -1) + p.Content = alterShortCodeEmailSubForm(p.Content, c.Alias, p.Slug.String, false) } } p.Content = strings.Replace(p.Content, "<!--emailsub-->", "", 1) // TODO: move this to function p.formatContent(app.cfg, cr.isCollOwner, true) tp := CollectionPostPage{ PublicPost: p, StaticPage: pageForReq(app, r), IsOwner: cr.isCollOwner, IsCustomDomain: cr.isCustomDomain, IsFound: postFound, Silenced: silenced, CollAlias: c.Alias, } 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) tp.Monetization = coll.Monetization tp.Verification = coll.Verification if tp.Verification != "" { // Fetch info for fediverse:creator tag ru, err := getRemoteUserFromURL(app, coll.Verification) if err != nil { if debugging { log.Info("showing rel=me tag, but no local handle for %s", coll.Verification) } } else { // Though we don't store handles with leading @, strip it here just in case tp.FediverseAuthor = "@" + strings.TrimLeft(ru.Handle, "@") } } 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 %s template: %v", postTmpl, 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 (p *Post) IsSans() bool { return p.Font == "sans" } func (p *Post) IsMonospace() bool { return p.Font == "mono" } func (rp *RawPost) UserFacingCreated() string { return rp.Created.Format(postMetaDateFormat) } func (rp *RawPost) Created8601() string { return rp.Created.Format("2006-01-02T15:04:05Z") } func (rp *RawPost) Updated8601() string { if rp.Updated.IsZero() { return "" } return rp.Updated.Format("2006-01-02T15:04:05Z") } var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|avif|avifs|webp|jxl|image)$`) func (p *Post) extractImages() { p.Images = extractImages(p.Content) } func extractImages(content string) []string { matches := extract.ExtractUrls(content) urls := map[string]bool{} for i := range matches { uRaw := matches[i].Text // Parse the extracted text so we can examine the path u, err := url.Parse(uRaw) if err != nil { continue } // Ensure the path looks like it leads to an image file if !imageURLRegex.MatchString(u.Path) { continue } urls[uRaw] = true } resURLs := make([]string, 0) for k := range urls { resURLs = append(resURLs, k) } return resURLs } diff --git a/prose/markdownParser.js b/prose/markdownParser.js index 30669d9..612759e 100644 --- a/prose/markdownParser.js +++ b/prose/markdownParser.js @@ -1,57 +1,110 @@ import { MarkdownParser } from "prosemirror-markdown"; import markdownit from "markdown-it"; import { writeFreelySchema } from "./schema"; +const md = markdownit("commonmark", { html: true }); + +// Map HTML comment shortcodes to their own token types so they are handled +// as special blocks rather than generic HTML. +const SHORTCODE_TOKENS = { + "": "readmore_block", + "": "emailsub_block", +}; + +md.core.ruler.push("shortcodes", (state) => { + const tokens = state.tokens; + // Iterate backwards so splicing at index i doesn't shift unvisited indices. + for (let i = tokens.length - 1; i >= 0; i--) { + const token = tokens[i]; + + if (token.type === "html_block") { + const content = token.content.trim(); + const mapped = SHORTCODE_TOKENS[content]; + if (mapped) { + token.type = mapped; + continue; + } + /* NOTE: future discussion support + // comment is an inline node — when it appears as a standalone html_block, + // wrap it in a paragraph so ProseMirror can place the inline node correctly. + if (content === "") { + const open = new state.Token("paragraph_open", "p", 1); + const inlineTok = new state.Token("inline", "", 0); + inlineTok.children = [new state.Token("comment_token", "", 0)]; + const close = new state.Token("paragraph_close", "p", -1); + tokens.splice(i, 1, open, inlineTok, close); + } + */ + continue; + } + + /* NOTE: future discussion support + // Handle appearing as html_inline inside an existing paragraph. + if (token.type === "inline" && token.children) { + for (const child of token.children) { + if (child.type === "html_inline" && child.content.trim() === "") { + child.type = "comment_token"; + } + } + } + */ + } +}); + export const writeFreelyMarkdownParser = new MarkdownParser( writeFreelySchema, - markdownit("commonmark", { html: true }), + md, { blockquote: { block: "blockquote" }, paragraph: { block: "paragraph" }, list_item: { block: "list_item" }, bullet_list: { block: "bullet_list" }, ordered_list: { block: "ordered_list", getAttrs: (tok) => ({ order: +tok.attrGet("start") || 1 }), }, heading: { block: "heading", getAttrs: (tok) => ({ level: +tok.tag.slice(1) }), }, code_block: { block: "code_block", noCloseToken: true }, fence: { block: "code_block", getAttrs: (tok) => ({ params: tok.info || "" }), noCloseToken: true, }, hr: { node: "horizontal_rule" }, image: { node: "image", getAttrs: (tok) => ({ src: tok.attrGet("src"), title: tok.attrGet("title") || null, alt: (tok.children !== null && typeof tok.children[0] !== 'undefined' ? tok.children[0].content : null), }), }, hardbreak: { node: "hard_break" }, em: { mark: "em" }, strong: { mark: "strong" }, link: { mark: "link", getAttrs: (tok) => ({ href: tok.attrGet("href"), title: tok.attrGet("title") || null, }), }, code_inline: { mark: "code", noCloseToken: true }, + readmore_block: { node: "readmore" }, + emailsub_block: { node: "emailsub" }, + comment_token: { node: "comment" }, html_block: { - node: "readmore", - getAttrs(token) { - // TODO: Give different attributes depending on the token content - return {}; - }, + node: "html_block", + getAttrs: (tok) => ({ content: tok.content }), + }, + html_inline: { + node: "html_inline", + getAttrs: (tok) => ({ content: tok.content }), }, } ); diff --git a/prose/markdownSerializer.js b/prose/markdownSerializer.js index 685703c..f25e23e 100644 --- a/prose/markdownSerializer.js +++ b/prose/markdownSerializer.js @@ -1,128 +1,142 @@ import { MarkdownSerializer } from "prosemirror-markdown"; function backticksFor(node, side) { const ticks = /`+/g; let m; let len = 0; if (node.isText) while ((m = ticks.exec(node.text))) len = Math.max(len, m[0].length); let result = len > 0 && side > 0 ? " `" : "`"; for (let i = 0; i < len; i++) result += "`"; if (len > 0 && side < 0) result += " "; return result; } function isPlainURL(link, parent, index, side) { if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) return false; const content = parent.child(index + (side < 0 ? -1 : 0)); if ( !content.isText || content.text != link.attrs.href || content.marks[content.marks.length - 1] != link ) return false; if (index == (side < 0 ? 1 : parent.childCount - 1)) return true; const next = parent.child(index + (side < 0 ? -2 : 1)); return !link.isInSet(next.marks); } export const writeFreelyMarkdownSerializer = new MarkdownSerializer( { readmore(state, node) { state.write("\n"); state.closeBlock(node); }, + emailsub(state, node) { + state.write("\n"); + state.closeBlock(node); + }, + comment(state, node) { + state.write(""); + }, + html_block(state, node) { + state.write(node.attrs.content); + state.closeBlock(node); + }, + html_inline(state, node) { + state.write(node.attrs.content); + }, blockquote(state, node) { state.wrapBlock("> ", null, node, () => state.renderContent(node)); }, code_block(state, node) { state.write(`\`\`\`${node.attrs.params || ""}\n`); state.text(node.textContent, false); state.ensureNewLine(); state.write("```"); state.closeBlock(node); }, heading(state, node) { state.write(`${state.repeat("#", node.attrs.level)} `); state.renderInline(node); state.closeBlock(node); }, horizontal_rule: function horizontal_rule(state, node) { state.write(node.attrs.markup || "---"); state.closeBlock(node); }, bullet_list(state, node) { node.attrs.tight = true; state.renderList(node, " ", () => `${node.attrs.bullet || "*"} `); }, ordered_list(state, node) { const start = node.attrs.order || 1; const maxW = String(start + node.childCount - 1).length; const space = state.repeat(" ", maxW + 2); state.renderList(node, space, (i) => { const nStr = String(start + i); return `${state.repeat(" ", maxW - nStr.length) + nStr}. `; }); }, list_item(state, node) { state.renderContent(node); }, paragraph(state, node) { state.renderInline(node); state.closeBlock(node); }, image(state, node) { state.write( `}${ node.attrs.title ? ` ${state.quote(node.attrs.title)}` : "" })` ); }, hard_break(state, node, parent, index) { for (let i = index + 1; i < parent.childCount; i += 1) if (parent.child(i).type !== node.type) { state.write("\\\n"); return; } }, text(state, node) { state.text(node.text || ""); }, }, { em: { open: "_", close: "_", mixable: true, expelEnclosingWhitespace: true, }, strong: { open: "**", close: "**", mixable: true, expelEnclosingWhitespace: true, }, link: { open(_state, mark, parent, index) { return isPlainURL(mark, parent, index, 1) ? "<" : "["; }, close(state, mark, parent, index) { return isPlainURL(mark, parent, index, -1) ? ">" : `](${state.esc(mark.attrs.href)}${ mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : "" })`; }, }, code: { open(_state, _mark, parent, index) { return backticksFor(parent.child(index), -1); }, close(_state, _mark, parent, index) { return backticksFor(parent.child(index - 1), 1); }, escape: false, }, } ); diff --git a/prose/menu.js b/prose/menu.js index c534f61..0e45217 100644 --- a/prose/menu.js +++ b/prose/menu.js @@ -1,32 +1,49 @@ import { MenuItem } from "prosemirror-menu"; import { buildMenuItems } from "prosemirror-example-setup"; +import { NodeSelection } from "prosemirror-state"; import { writeFreelySchema } from "./schema"; function canInsert(state, nodeType, attrs) { let $from = state.selection.$from; for (let d = $from.depth; d >= 0; d--) { let index = $from.index(d); if ($from.node(d).canReplaceWith(index, index, nodeType, attrs)) return true; } return false; } const ReadMoreItem = new MenuItem({ label: "Read more", select: (state) => canInsert(state, writeFreelySchema.nodes.readmore), run(state, dispatch) { dispatch( state.tr.replaceSelectionWith(writeFreelySchema.nodes.readmore.create()) ); }, }); export const getMenu = () => { - const menuContent = [ - ...buildMenuItems(writeFreelySchema).fullMenu, - [ReadMoreItem], - ]; - return menuContent; + const builtItems = buildMenuItems(writeFreelySchema); + const { toggleLink } = builtItems; + + const patchedLink = new MenuItem({ + ...toggleLink.spec, + select(state) { + if ( + state.selection instanceof NodeSelection && + state.selection.node.type === writeFreelySchema.nodes.comment + ) { + return false; + } + return toggleLink.spec.select ? toggleLink.spec.select(state) : true; + }, + }); + + const fullMenu = builtItems.fullMenu.map((group) => + group.map((item) => (item === toggleLink ? patchedLink : item)) + ); + + return [...fullMenu, [ReadMoreItem]]; }; diff --git a/prose/package.json b/prose/package.json index 1584748..03aabdc 100644 --- a/prose/package.json +++ b/prose/package.json @@ -1,32 +1,32 @@ { "name": "prose", "version": "1.0.0", "description": "", "main": "prose.js", "dependencies": { "babel-core": "^6.26.3", "babel-preset-es2015": "^6.24.1", "markdown-it": "^12.0.4", "prosemirror-example-setup": "github:writefreely/prosemirror-example-setup", "prosemirror-keymap": "^1.1.4", "prosemirror-markdown": "github:writefreely/prosemirror-markdown", "prosemirror-model": "^1.9.1", "prosemirror-state": "^1.3.2", "prosemirror-view": "^1.14.2", "webpack": "^4.42.0", "webpack-cli": "^3.3.11" }, "devDependencies": { "@babel/core": "^7.8.7", "@babel/preset-env": "^7.9.0", "babel-loader": "^8.0.6", "prettier": "^2.2.1" }, "scripts": { - "develop": "webpack --mode development --watch", - "build": "webpack --mode production" + "develop": "NODE_OPTIONS=--openssl-legacy-provider webpack --mode development --watch", + "build": "NODE_OPTIONS=--openssl-legacy-provider webpack --mode production" }, "keywords": [], "author": "", "license": "ISC" } diff --git a/prose/schema.js b/prose/schema.js index eeebbd9..91a2be9 100644 --- a/prose/schema.js +++ b/prose/schema.js @@ -1,19 +1,62 @@ import { schema } from "prosemirror-markdown"; import { Schema } from "prosemirror-model"; export const writeFreelySchema = new Schema({ nodes: schema.spec.nodes .addToEnd("readmore", { inline: false, content: "", group: "block", draggable: true, toDOM: (node) => [ "div", { class: "editorreadmore" }, "Read more...", ], parseDOM: [{ tag: "div.editorreadmore" }], + }) + .addToEnd("emailsub", { + inline: false, + content: "", + group: "block", + draggable: true, + toDOM: () => [ + "div", { id: "emailsub", contenteditable: "false" }, + ["form", {}, + ["input", { type: "email", disabled: true, placeholder: "me@example.com" }], + ["input", { type: "submit", disabled: true, value: "Subscribe" }], + ], + ], + parseDOM: [{ tag: "div#emailsub" }], + }) + .addToEnd("comment", { + inline: true, + content: "", + group: "inline", + draggable: false, + toDOM: () => ["a", { class: "editor-comment" }, "Discuss..."], + parseDOM: [{ tag: "a.editor-comment" }], + }) + .addToEnd("html_block", { + attrs: { content: { default: "" } }, + content: "", + group: "block", + marks: "", + draggable: true, + toDOM: (node) => [ + "div", + { class: "editor-html-block", contenteditable: "false" }, + node.attrs.content, + ], + parseDOM: [{ tag: "div.editor-html-block", getAttrs: (dom) => ({ content: dom.textContent }) }], + }) + .addToEnd("html_inline", { + attrs: { content: { default: "" } }, + inline: true, + content: "", + group: "inline", + toDOM: (node) => ["code", { class: "editor-html-inline" }, node.attrs.content], + parseDOM: [{ tag: "code.editor-html-inline", getAttrs: (dom) => ({ content: dom.textContent }) }], }), marks: schema.spec.marks, }); diff --git a/read.go b/read.go index 46f07ee..9223377 100644 --- a/read.go +++ b/read.go @@ -1,340 +1,344 @@ /* * Copyright © 2018-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "database/sql" "fmt" "html/template" "math" "net/http" "strconv" + "strings" "time" . "github.com/gorilla/feeds" "github.com/gorilla/mux" stripmd "github.com/writeas/go-strip-markdown/v2" "github.com/writeas/impart" "github.com/writeas/web-core/log" "github.com/writeas/web-core/memo" "github.com/writefreely/writefreely/page" ) const ( tlFeedLimit = 100 tlAPIPageLimit = 10 tlMaxAuthorPosts = 5 tlPostsPerPage = 16 tlMaxPostCache = 250 tlCacheDur = 10 * time.Minute ) type localTimeline struct { m *memo.Memo posts *[]PublicPost // Configuration values postsPerPage int } type readPublication struct { page.StaticPage Posts *[]PublicPost CurrentPage int TotalPages int SelTopic string IsAdmin bool CanInvite bool // Customizable page content ContentTitle string Content template.HTML } func initLocalTimeline(app *App) { app.timeline = &localTimeline{ postsPerPage: tlPostsPerPage, m: memo.New(app.FetchPublicPosts, tlCacheDur), } } // satisfies memo.Func func (app *App) FetchPublicPosts() (interface{}, error) { // Conditions limit := fmt.Sprintf("LIMIT %d", tlMaxPostCache) // This is better than the hard limit when limiting posts from individual authors // ageCond := `p.created >= ` + app.db.dateSub(3, "month") + ` AND ` // Finds all public posts and posts in a public collection published during the owner's active subscription period and within the last 3 months rows, err := app.db.Query(`SELECT p.id, c.id, alias, c.title, p.slug, p.title, p.content, p.text_appearance, p.language, p.rtl, p.created, p.updated FROM collections c LEFT JOIN posts p ON p.collection_id = c.id LEFT JOIN users u ON u.id = p.owner_id WHERE c.privacy = 1 AND (p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) AND u.status = 0 ORDER BY p.created DESC ` + limit) if err != nil { log.Error("Failed selecting from posts: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts." + err.Error()} } defer rows.Close() ap := map[string]uint{} posts := []PublicPost{} for rows.Next() { p := &Post{} c := &Collection{} var alias, title sql.NullString err = rows.Scan(&p.ID, &c.ID, &alias, &title, &p.Slug, &p.Title, &p.Content, &p.Font, &p.Language, &p.RTL, &p.Created, &p.Updated) if err != nil { log.Error("[READ] Unable to scan row, skipping: %v", err) continue } c.hostName = app.cfg.App.Host isCollectionPost := alias.Valid if isCollectionPost { c.Alias = alias.String if c.Alias != "" && ap[c.Alias] == tlMaxAuthorPosts { // Don't add post if we've hit the post-per-author limit continue } c.Public = true c.Title = title.String c.Monetization = app.db.GetCollectionAttribute(c.ID, "monetization_pointer") } p.extractData() p.handlePremiumContent(c, false, false, app.cfg) p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), "", app.cfg)) fp := p.processPost() if isCollectionPost { fp.Collection = &CollectionObj{Collection: *c} } posts = append(posts, fp) ap[c.Alias]++ } return posts, nil } func viewLocalTimelineAPI(app *App, w http.ResponseWriter, r *http.Request) error { updateTimelineCache(app.timeline, false) skip, _ := strconv.Atoi(r.FormValue("skip")) posts := []PublicPost{} for i := skip; i < skip+tlAPIPageLimit && i < len(*app.timeline.posts); i++ { posts = append(posts, (*app.timeline.posts)[i]) } return impart.WriteSuccess(w, posts, http.StatusOK) } func viewLocalTimeline(app *App, w http.ResponseWriter, r *http.Request) error { if !app.cfg.App.LocalTimeline { return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."} } vars := mux.Vars(r) var p int page := 1 p, _ = strconv.Atoi(vars["page"]) if p > 0 { page = p } return showLocalTimeline(app, w, r, page, vars["author"], vars["tag"]) } // updateTimelineCache will reset and update the cache if it is stale or // the boolean passed in is true. func updateTimelineCache(tl *localTimeline, reset bool) { if reset { tl.m.Reset() } // Fetch posts if the cache is empty, has been reset or enough time has // passed since last cache. if tl.posts == nil || reset || tl.m.Invalidate() { log.Info("[READ] Updating post cache") postsInterfaces, err := tl.m.Get() if err != nil { log.Error("[READ] Unable to cache posts: %v", err) } else { castPosts := postsInterfaces.([]PublicPost) tl.posts = &castPosts } } } func showLocalTimeline(app *App, w http.ResponseWriter, r *http.Request, page int, author, tag string) error { updateTimelineCache(app.timeline, false) pl := len(*(app.timeline.posts)) ttlPages := int(math.Ceil(float64(pl) / float64(app.timeline.postsPerPage))) start := 0 if page > 1 { start = app.timeline.postsPerPage * (page - 1) if start > pl { return impart.HTTPError{http.StatusFound, fmt.Sprintf("/read/p/%d", ttlPages)} } } end := app.timeline.postsPerPage * page if end > pl { end = pl } var posts []PublicPost if author != "" { posts = []PublicPost{} for _, p := range *app.timeline.posts { if author == "anonymous" { if p.Collection == nil { posts = append(posts, p) } } else if p.Collection != nil && p.Collection.Alias == author { posts = append(posts, p) } } } else if tag != "" { posts = []PublicPost{} for _, p := range *app.timeline.posts { if p.HasTag(tag) { posts = append(posts, p) } } } else { posts = *app.timeline.posts posts = posts[start:end] } d := &readPublication{ StaticPage: pageForReq(app, r), Posts: &posts, CurrentPage: page, TotalPages: ttlPages, SelTopic: tag, } u := getUserSession(app, r) d.IsAdmin = u != nil && u.IsAdmin() d.CanInvite = canUserInvite(app.cfg, d.IsAdmin) c, err := getReaderSection(app) if err != nil { return err } d.ContentTitle = c.Title.String d.Content = template.HTML(applyMarkdown([]byte(c.Content), "", app.cfg)) err = templates["read"].ExecuteTemplate(w, "base", d) if err != nil { log.Error("Unable to render reader: %v", err) fmt.Fprintf(w, ":(") } return nil } // NextPageURL provides a full URL for the next page of collection posts func (c *readPublication) NextPageURL(n int) string { return fmt.Sprintf("/read/p/%d", n+1) } // PrevPageURL provides a full URL for the previous page of collection posts, // returning a /page/N result for pages >1 func (c *readPublication) PrevPageURL(n int) string { if n == 2 { // Previous page is 1; no need for /p/ prefix return "/read" } return fmt.Sprintf("/read/p/%d", n-1) } // handlePostIDRedirect handles a route where a post ID is given and redirects // the user to the canonical post URL. func handlePostIDRedirect(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) postID := vars["post"] p, err := app.db.GetPost(postID, 0) if err != nil { return err } if !p.CollectionID.Valid { // No collection; send to normal URL // NOTE: not handling single user blogs here since this handler is only used for the Reader return impart.HTTPError{http.StatusFound, app.cfg.App.Host + "/" + postID + ".md"} } c, err := app.db.GetCollectionBy("id = ?", fmt.Sprintf("%d", p.CollectionID.Int64)) if err != nil { return err } c.hostName = app.cfg.App.Host // Retrieve collection information and send user to canonical URL return impart.HTTPError{http.StatusFound, c.CanonicalURL() + p.Slug.String} } func viewLocalTimelineFeed(app *App, w http.ResponseWriter, req *http.Request) error { if !app.cfg.App.LocalTimeline { return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."} } + if !strings.HasSuffix(req.URL.Path, "/") { + return impart.HTTPError{http.StatusMovedPermanently, "/read/feed/"} + } updateTimelineCache(app.timeline, false) feed := &Feed{ Title: app.cfg.App.SiteName + " Reader", Link: &Link{Href: app.cfg.App.Host}, Description: "Read the latest posts from " + app.cfg.App.SiteName + ".", Created: time.Now(), } c := 0 var title, permalink, author string for _, p := range *app.timeline.posts { if c == tlFeedLimit { break } title = p.PlainDisplayTitle() permalink = p.CanonicalURL(app.cfg.App.Host) if p.Collection != nil { author = p.Collection.Title } else { author = "Anonymous" } i := &Item{ Id: app.cfg.App.Host + "/read/a/" + p.ID, Title: title, Link: &Link{Href: permalink}, Description: "", Content: applyMarkdown([]byte(p.Content), "", app.cfg), Author: &Author{author, ""}, Created: p.Created, Updated: p.Updated, } feed.Items = append(feed.Items, i) c++ } rss, err := feed.ToRss() if err != nil { return err } fmt.Fprint(w, rss) return nil } diff --git a/routes.go b/routes.go index a88f1e3..7447b62 100644 --- a/routes.go +++ b/routes.go @@ -1,250 +1,251 @@ /* * Copyright © 2018-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "net/http" "net/url" "path/filepath" "strings" "github.com/gorilla/csrf" "github.com/gorilla/mux" "github.com/writeas/go-webfinger" "github.com/writeas/web-core/log" "github.com/writefreely/go-nodeinfo" ) // InitStaticRoutes adds routes for serving static files. // TODO: this should just be a func, not method func (app *App) InitStaticRoutes(r *mux.Router) { // Handle static files fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir))) fs = cacheControl(fs) app.shttp = http.NewServeMux() app.shttp.Handle("/", fs) r.PathPrefix("/").Handler(fs) } // InitRoutes adds dynamic routes for the given mux.Router. func InitRoutes(apper Apper, r *mux.Router) *mux.Router { // Create handler handler := NewWFHandler(apper) // Set up routes hostSubroute := apper.App().cfg.App.Host[strings.Index(apper.App().cfg.App.Host, "://")+3:] if apper.App().cfg.App.SingleUser { hostSubroute = "{domain}" } else { if strings.HasPrefix(hostSubroute, "localhost") { hostSubroute = "localhost" } } if apper.App().cfg.App.SingleUser { log.Info("Adding %s routes (single user)...", hostSubroute) } else { log.Info("Adding %s routes (multi-user)...", hostSubroute) } // Primary app routes write := r.PathPrefix("/").Subrouter() // Federation endpoint configurations wf := webfinger.Default(wfResolver{apper.App().db, apper.App().cfg}) wf.NoTLSHandler = nil // Federation endpoints // host-meta write.HandleFunc("/.well-known/host-meta", handler.Web(handleViewHostMeta, UserLevelReader)) // webfinger write.HandleFunc(webfinger.WebFingerPath, handler.LogHandlerFunc(http.HandlerFunc(wf.Webfinger))) // nodeinfo niCfg := nodeInfoConfig(apper.App().db, apper.App().cfg) ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{apper.App().cfg, apper.App().db}) write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover))) write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo))) // handle mentions write.HandleFunc("/@/{handle}", handler.Web(handleViewMention, UserLevelReader)) configureSlackOauth(handler, write, apper.App()) configureWriteAsOauth(handler, write, apper.App()) configureGitlabOauth(handler, write, apper.App()) configureGenericOauth(handler, write, apper.App()) configureGiteaOauth(handler, write, apper.App()) // Set up dynamic page handlers // Handle auth auth := write.PathPrefix("/api/auth/").Subrouter() if apper.App().cfg.App.OpenRegistration { auth.HandleFunc("/signup", handler.All(apiSignup)).Methods("POST") } auth.HandleFunc("/login", handler.All(login)).Methods("POST") auth.HandleFunc("/read", handler.WebErrors(handleWebCollectionUnlock, UserLevelNone)).Methods("POST") auth.HandleFunc("/me", handler.All(handleAPILogout)).Methods("DELETE") // Handle logged in user sections me := write.PathPrefix("/me").Subrouter() me.HandleFunc("/", handler.Redirect("/me", UserLevelUser)) me.HandleFunc("/c", handler.Redirect("/me/c/", UserLevelUser)).Methods("GET") me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET") me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET") me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET") me.HandleFunc("/c/{collection}/subscribers", handler.User(handleViewSubscribers)).Methods("GET") me.Path("/delete").Handler(csrf.Protect(apper.App().keys.CSRFKey)(handler.User(handleUserDelete))).Methods("POST") me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET") me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET") me.HandleFunc("/posts/export.csv", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/posts/export.zip", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET") me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET") me.HandleFunc("/import", handler.User(viewImport)).Methods("GET") me.Path("/settings").Handler(csrf.Protect(apper.App().keys.CSRFKey)(handler.User(viewSettings))).Methods("GET") me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET") me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET") write.HandleFunc("/api/me", handler.All(viewMeAPI)).Methods("GET") apiMe := write.PathPrefix("/api/me/").Subrouter() apiMe.HandleFunc("/", handler.All(viewMeAPI)).Methods("GET") apiMe.HandleFunc("/posts", handler.UserWebAPI(viewMyPostsAPI)).Methods("GET") apiMe.HandleFunc("/collections", handler.UserAPI(viewMyCollectionsAPI)).Methods("GET") apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST") apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST") apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST") apiMe.HandleFunc("/import", handler.User(handleImport)).Methods("POST") apiMe.HandleFunc("/oauth/remove", handler.User(removeOauth)).Methods("POST") // Sign up validation write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST") write.HandleFunc("/api/markdown", handler.All(handleRenderMarkdown)).Methods("POST") instanceURL, _ := url.Parse(apper.App().Config().App.Host) host := instanceURL.Host // Handle collections write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST") apiColls := write.PathPrefix("/api/collections/").Subrouter() apiColls.HandleFunc("/monetization-pointer", handler.PlainTextAPI(handleSPSPEndpoint)).Methods("GET") apiColls.HandleFunc("/"+host, handler.AllReader(fetchCollection)).Methods("GET") apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.AllReader(fetchCollection)).Methods("GET") apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE") apiColls.HandleFunc("/{alias}/posts", handler.AllReader(fetchCollectionPosts)).Methods("GET") apiColls.HandleFunc("/{alias}/posts", handler.All(newPost)).Methods("POST") apiColls.HandleFunc("/{alias}/posts/{post}", handler.AllReader(fetchPost)).Methods("GET") apiColls.HandleFunc("/{alias}/posts/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST") apiColls.HandleFunc("/{alias}/posts/{post}/splitcontent", handler.AllReader(handleGetSplitContent)).Methods("GET", "POST") apiColls.HandleFunc("/{alias}/posts/{post}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET") apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST") apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST") apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST") apiColls.HandleFunc("/{alias}/email/subscribers/export.csv", handler.Download(handleExportEmailSubscriptions, UserLevelUser)).Methods("GET") apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleCreateEmailSubscription)).Methods("POST") apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleDeleteEmailSubscription)).Methods("DELETE") apiColls.HandleFunc("/{collection}/email/unsubscribe", handler.All(handleDeleteEmailSubscription)).Methods("GET") apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST") apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET") apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET") apiColls.HandleFunc("/{alias}/followers", handler.AllReader(handleFetchCollectionFollowers)).Methods("GET") // Handle posts write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST") posts := write.PathPrefix("/api/posts/").Subrouter() posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST") posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST") posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.AllReader(fetchPost)).Methods("GET") posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.All(existingPost)).Methods("POST", "PUT") posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.All(deletePost)).Methods("DELETE") posts.HandleFunc("/{post:[a-zA-Z0-9]+}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET") write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST") write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST") write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET") write.HandleFunc("/admin/monitor", handler.Admin(handleViewAdminMonitor)).Methods("GET") write.HandleFunc("/admin/settings", handler.Admin(handleViewAdminSettings)).Methods("GET") write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET") write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET") write.HandleFunc("/admin/user/{username}/delete", handler.Admin(handleAdminDeleteUser)).Methods("POST") write.HandleFunc("/admin/user/{username}/status", handler.Admin(handleAdminToggleUserStatus)).Methods("POST") write.HandleFunc("/admin/user/{username}/passphrase", handler.Admin(handleAdminResetUserPass)).Methods("POST") write.HandleFunc("/admin/pages", handler.Admin(handleViewAdminPages)).Methods("GET") write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET") write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST") write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST") write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET") // Handle special pages first write.Path("/reset").Handler(csrf.Protect(apper.App().keys.CSRFKey)(handler.Web(viewResetPassword, UserLevelNoneRequired))) write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired)) write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET") // TODO: show a reader-specific 404 page if the function is disabled write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader)) RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter()) draftEditPrefix := "" if apper.App().cfg.App.SingleUser { draftEditPrefix = "/d" write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET") } else { write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET") } // All the existing stuff write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelUser)).Methods("GET") write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelUser)).Methods("GET") // Collections if apper.App().cfg.App.SingleUser { RouteCollections(handler, write.PathPrefix("/").Subrouter()) } else { write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelReader)) write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelReader)) RouteCollections(handler, write.PathPrefix("/{prefix:[@~$!\\-+]?}{collection}").Subrouter()) // Posts } write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional)) write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional)) return r } 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("/archive/", handler.Web(handleViewCollection, UserLevelReader)) r.HandleFunc("/{archive:archive}/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader)) r.HandleFunc("/lang:{lang:[a-z]{2}}", handler.Web(handleViewCollectionLang, UserLevelOptional)) r.HandleFunc("/lang:{lang:[a-z]{2}}/page/{page:[0-9]+}", handler.Web(handleViewCollectionLang, UserLevelOptional)) r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader)) r.HandleFunc("/tag:{tag}/page/{page:[0-9]+}", handler.Web(handleViewCollectionTag, UserLevelReader)) r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader)) r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap)) r.HandleFunc("/feed/", handler.AllReader(ViewFeed)) r.HandleFunc("/email/confirm/{subscriber}", handler.All(handleConfirmEmailSubscription)).Methods("GET") r.HandleFunc("/email/unsubscribe/{subscriber}", handler.All(handleDeleteEmailSubscription)).Methods("GET") r.HandleFunc("/{slug}", handler.CollectionPostOrStatic) r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser)) r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser)) r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelReader)).Methods("GET") } func RouteRead(handler *Handler, readPerm UserLevelFunc, r *mux.Router) { r.HandleFunc("/api/posts", handler.Web(viewLocalTimelineAPI, readPerm)) r.HandleFunc("/p/{page}", handler.Web(viewLocalTimeline, readPerm)) + r.HandleFunc("/feed", handler.Web(viewLocalTimelineFeed, readPerm)) r.HandleFunc("/feed/", handler.Web(viewLocalTimelineFeed, readPerm)) r.HandleFunc("/t/{tag}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/a/{post}", handler.Web(handlePostIDRedirect, readPerm)) r.HandleFunc("/{author}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/", handler.Web(viewLocalTimeline, readPerm)) } diff --git a/static/img/ic_preview@2x.png b/static/img/ic_preview@2x.png new file mode 100644 index 0000000..169927a Binary files /dev/null and b/static/img/ic_preview@2x.png differ diff --git a/static/img/ic_preview_dark@2x.png b/static/img/ic_preview_dark@2x.png new file mode 100644 index 0000000..f677b72 Binary files /dev/null and b/static/img/ic_preview_dark@2x.png differ diff --git a/static/js/modals.js b/static/js/modals.js index 0910d7e..912df8a 100644 --- a/static/js/modals.js +++ b/static/js/modals.js @@ -1,24 +1,24 @@ /* * Copyright © 2016-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ function showModal(id) { document.getElementById('overlay').style.display = 'block'; document.getElementById('modal-'+id).style.display = 'block'; } var closeModals = function(e) { e.preventDefault(); document.getElementById('overlay').style.display = 'none'; var modals = document.querySelectorAll('.modal'); for (var i=0; iPlease add an email address and/or passphrase so you can log in again later.
Change your account settings here.
These are your linked external accounts.
{{ range $oauth_account := .OauthAccounts }} {{ end }}Connect additional accounts to enable logging in with those providers, instead of using your username and password.
Permanently erase all your data, with no way to recover it.
This action cannot be undone. It will immediately and permanently erase your account, including your blogs and posts. Before continuing, you might want to export your data.
If you're sure, please type {{.Username}} to confirm.