Compare commits

...

132 Commits

Author SHA1 Message Date
Matt Baer 038a80c25e
Merge pull request #893 from writefreely/consistent-reader-nav
Fix Admin and Invite links never showing on Reader nav
2024-04-17 12:44:28 -04:00
Matt Baer 9ece6682ef
Merge pull request #930 from tkngaejcpi/develop
support more image formats
2024-04-17 12:43:50 -04:00
Matt Baer 41e1989345
Merge pull request #982 from writefreely/dependabot/go_modules/golang.org/x/net-0.22.0
Bump golang.org/x/net from 0.20.0 to 0.22.0
2024-04-03 14:25:17 -04:00
Matt Baer 34d902062f
Merge pull request #927 from writefreely/dependabot/go_modules/github.com/stretchr/testify-1.9.0
Bump github.com/stretchr/testify from 1.8.4 to 1.9.0
2024-04-03 14:23:13 -04:00
dependabot[bot] ed9ff51b68
Bump golang.org/x/net from 0.20.0 to 0.22.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.20.0 to 0.22.0.
- [Commits](https://github.com/golang/net/compare/v0.20.0...v0.22.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-01 22:46:41 +00:00
Riley Chang 83ffea7fa0
support more image formats 2024-03-04 22:48:51 +08:00
dependabot[bot] 3dd0a9b8dc
Bump github.com/stretchr/testify from 1.8.4 to 1.9.0
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.8.4 to 1.9.0.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.8.4...v1.9.0)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-01 22:40:53 +00:00
Matt Baer 427f4980b9
Merge pull request #874 from claabs/docker-fixes
Version number and healthcheck fixes for Docker image
2024-02-20 10:19:30 -05:00
Matt Baer e34a58d0ef Fix Admin and Invite links never showing on Reader nav 2024-02-20 10:02:14 -05:00
Matt Baer 6d547040ef
Merge pull request #878 from c7io-dev/snullp-patch-1
Add "Import posts" to base.tmpl to be consistent with /me/* nav bar
2024-02-20 09:55:00 -05:00
Matt Baer 216f36f47b
Merge pull request #883 from elkcityhazard/develop
add f.created to join, add Created to Scan
2024-02-20 09:32:20 -05:00
Andrew M McCall a352a3518a add f.created to join, add Created to Scan 2024-02-14 19:48:47 -05:00
Big Squirrel 1a3f3f0ec6
Add "Import posts" to base.tmpl to be consistent with /me/* nav bar 2024-02-09 12:51:00 -08:00
charlocharlie 306ca173c6 Include .git context in Docker build for UI version number
Fixes #873
2024-02-06 14:31:18 -06:00
Matt Baer 22de459a72
Merge pull request #854 from writefreely/api-inconsistencies
Fix Collection property serialization on API
2024-02-02 09:01:57 -05:00
Matt Baer 5be1f2451c Bump version to 0.15 2024-02-02 14:50:48 +01:00
Matt Baer 3a53353ed8
Merge pull request #861 from writefreely/dependabot/go_modules/github.com/mattn/go-sqlite3-1.14.21
Bump github.com/mattn/go-sqlite3 from 1.14.19 to 1.14.21
2024-02-01 18:53:18 -05:00
dependabot[bot] 56cad35b19
Bump github.com/mattn/go-sqlite3 from 1.14.19 to 1.14.21
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.19 to 1.14.21.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.19...v1.14.21)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-sqlite3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-01 22:40:03 +00:00
Matt Baer ff84c7aa4d
Merge pull request #826 from andi1984/issue-612-rtl
fix: RTL support on post textarea
2024-02-01 08:29:18 -05:00
Andreas Sander 4c6169d55d fix: RTL support on post textarea
Fixing right to left (short: RTL) support for respective RTL languages
by adding auto-detection for the user content's directionality based on
the text's language.

Fixes #612
2024-01-10 23:30:04 +01:00
Matt Baer ab1b2922cc
Merge pull request #856 from writefreely/dependabot/go_modules/golang.org/x/net-0.20.0
Bump golang.org/x/net from 0.17.0 to 0.20.0
2024-01-10 16:13:39 -05:00
dependabot[bot] 9401d047d6
Bump golang.org/x/net from 0.17.0 to 0.20.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.17.0 to 0.20.0.
- [Commits](https://github.com/golang/net/compare/v0.17.0...v0.20.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-10 21:13:04 +00:00
Matt Baer 54b46b61db
Merge pull request #855 from writefreely/dependabot/go_modules/golang.org/x/crypto-0.18.0
Bump golang.org/x/crypto from 0.14.0 to 0.18.0
2024-01-10 16:12:18 -05:00
Matt Baer 235a3ee143
Merge pull request #849 from writefreely/dependabot/go_modules/github.com/urfave/cli/v2-2.27.1
Bump github.com/urfave/cli/v2 from 2.25.7 to 2.27.1
2024-01-10 16:11:41 -05:00
Matt Baer f4accd5064
Merge pull request #848 from writefreely/dependabot/go_modules/github.com/mattn/go-sqlite3-1.14.19
Bump github.com/mattn/go-sqlite3 from 1.14.17 to 1.14.19
2024-01-10 16:10:37 -05:00
Matt Baer bc00ae1963
Merge pull request #841 from writefreely/dependabot/go_modules/github.com/gorilla/schema-1.2.1
Bump github.com/gorilla/schema from 1.2.0 to 1.2.1
2024-01-10 16:09:38 -05:00
dependabot[bot] 775d86cb00
Bump github.com/gorilla/schema from 1.2.0 to 1.2.1
Bumps [github.com/gorilla/schema](https://github.com/gorilla/schema) from 1.2.0 to 1.2.1.
- [Release notes](https://github.com/gorilla/schema/releases)
- [Commits](https://github.com/gorilla/schema/compare/v1.2.0...v1.2.1)

---
updated-dependencies:
- dependency-name: github.com/gorilla/schema
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-10 21:08:38 +00:00
Matt Baer 90e564870d
Merge pull request #838 from writefreely/dependabot/go_modules/github.com/gorilla/feeds-1.1.2
Bump github.com/gorilla/feeds from 1.1.1 to 1.1.2
2024-01-10 16:06:24 -05:00
dependabot[bot] 62c26e78ba
Bump github.com/gorilla/feeds from 1.1.1 to 1.1.2
Bumps [github.com/gorilla/feeds](https://github.com/gorilla/feeds) from 1.1.1 to 1.1.2.
- [Release notes](https://github.com/gorilla/feeds/releases)
- [Commits](https://github.com/gorilla/feeds/compare/v1.1.1...v1.1.2)

---
updated-dependencies:
- dependency-name: github.com/gorilla/feeds
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-10 21:04:53 +00:00
dependabot[bot] 69002fdcbf
Bump golang.org/x/crypto from 0.14.0 to 0.18.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.14.0 to 0.18.0.
- [Commits](https://github.com/golang/crypto/compare/v0.14.0...v0.18.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-10 21:04:33 +00:00
dependabot[bot] 4acf08d9e9
Bump github.com/mattn/go-sqlite3 from 1.14.17 to 1.14.19
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.17 to 1.14.19.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.17...v1.14.19)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-sqlite3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-10 21:04:27 +00:00
Matt Baer df7fee2018
Merge pull request #837 from writefreely/dependabot/go_modules/github.com/fatih/color-1.16.0
Bump github.com/fatih/color from 1.15.0 to 1.16.0
2024-01-10 16:03:54 -05:00
Matt Baer c64c7c77ae
Merge pull request #836 from writefreely/dependabot/go_modules/github.com/gorilla/sessions-1.2.2
Bump github.com/gorilla/sessions from 1.2.1 to 1.2.2
2024-01-10 16:03:40 -05:00
dependabot[bot] e788b90b04
Bump github.com/gorilla/sessions from 1.2.1 to 1.2.2
Bumps [github.com/gorilla/sessions](https://github.com/gorilla/sessions) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/gorilla/sessions/releases)
- [Commits](https://github.com/gorilla/sessions/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: github.com/gorilla/sessions
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-10 21:02:58 +00:00
Matt Baer 66f049cc39
Merge pull request #834 from writefreely/dependabot/go_modules/github.com/gorilla/mux-1.8.1
Bump github.com/gorilla/mux from 1.8.0 to 1.8.1
2024-01-10 16:01:06 -05:00
Matt Baer ff07c447ee
Merge pull request #833 from writefreely/dependabot/go_modules/github.com/gorilla/csrf-1.7.2
Bump github.com/gorilla/csrf from 1.7.1 to 1.7.2
2024-01-10 16:00:16 -05:00
Matt Baer d33a556732
Merge pull request #823 from writefreely/contact-links
Add Contact page links to footers
2024-01-10 15:57:49 -05:00
Matt Baer 737d76176a Fix indentation in footer.tmpl 2024-01-10 15:57:31 -05:00
dependabot[bot] 8e6ddc1993
Bump github.com/urfave/cli/v2 from 2.25.7 to 2.27.1
Bumps [github.com/urfave/cli/v2](https://github.com/urfave/cli) from 2.25.7 to 2.27.1.
- [Release notes](https://github.com/urfave/cli/releases)
- [Changelog](https://github.com/urfave/cli/blob/main/docs/CHANGELOG.md)
- [Commits](https://github.com/urfave/cli/compare/v2.25.7...v2.27.1)

---
updated-dependencies:
- dependency-name: github.com/urfave/cli/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-01 22:29:53 +00:00
Matt Baer b85afa1ea6
Merge pull request #828 from d4rklynk/dockerfile
Dockerfile
2023-12-01 17:49:54 -05:00
dependabot[bot] 6b8cc591cc
Bump github.com/fatih/color from 1.15.0 to 1.16.0
Bumps [github.com/fatih/color](https://github.com/fatih/color) from 1.15.0 to 1.16.0.
- [Release notes](https://github.com/fatih/color/releases)
- [Commits](https://github.com/fatih/color/compare/v1.15.0...v1.16.0)

---
updated-dependencies:
- dependency-name: github.com/fatih/color
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-01 22:47:13 +00:00
dependabot[bot] 859a4b37e5
Bump github.com/gorilla/mux from 1.8.0 to 1.8.1
Bumps [github.com/gorilla/mux](https://github.com/gorilla/mux) from 1.8.0 to 1.8.1.
- [Release notes](https://github.com/gorilla/mux/releases)
- [Commits](https://github.com/gorilla/mux/compare/v1.8.0...v1.8.1)

---
updated-dependencies:
- dependency-name: github.com/gorilla/mux
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-01 22:46:48 +00:00
dependabot[bot] 3caa33b9bf
Bump github.com/gorilla/csrf from 1.7.1 to 1.7.2
Bumps [github.com/gorilla/csrf](https://github.com/gorilla/csrf) from 1.7.1 to 1.7.2.
- [Release notes](https://github.com/gorilla/csrf/releases)
- [Commits](https://github.com/gorilla/csrf/compare/v1.7.1...v1.7.2)

---
updated-dependencies:
- dependency-name: github.com/gorilla/csrf
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-01 22:46:41 +00:00
Matt Baer e932467ac9
Merge pull request #822 from writefreely/custom-css-config
Look for custom CSS in static_parent_dir
2023-12-01 17:34:03 -05:00
d4rklynk aac4514577 Fix healthcheck URL 2023-11-18 14:30:54 +01:00
d4rklynk 21f5073717 Fix port 2023-11-18 14:24:37 +01:00
d4rklynk 64d1a2f536 Update Dockerfile 2023-11-18 14:18:39 +01:00
Matt Baer e4e059cb13 Fix Collection property serialization on API
Use standard string instead of sql.NullString for `style_sheet`, `script`, and `signature`.

Addresses #820
2023-11-07 10:54:16 -05:00
Matt Baer feab841609 Add Contact page links to footers 2023-11-07 10:21:24 -05:00
Matt Baer 3e7d236c6d
Merge pull request #528 from isaacsu/protect-drafts
Protect drafts if they are part of a Private or Protected collection
2023-11-07 10:12:19 -05:00
Matt Baer 289730e24a Look for custom CSS in static_parent_dir
Previously, it would only check the current directory instead of using the configured
`static_parent_dir`. This fixes that.

Closes #792
2023-11-07 09:06:50 -05:00
Matt Baer a1becfdc83
Merge pull request #799 from heyakyra/twitter-card-fix-large-preview
Conditionally use twitter large summary card format when an image is available
2023-11-06 16:31:10 -05:00
Matt Baer 0bf0b425ee
Merge pull request #811 from writefreely/dependabot/go_modules/github.com/microcosm-cc/bluemonday-1.0.26
Bump github.com/microcosm-cc/bluemonday from 1.0.25 to 1.0.26
2023-11-02 13:40:15 -04:00
dependabot[bot] 10994c532f
Bump github.com/microcosm-cc/bluemonday from 1.0.25 to 1.0.26
Bumps [github.com/microcosm-cc/bluemonday](https://github.com/microcosm-cc/bluemonday) from 1.0.25 to 1.0.26.
- [Release notes](https://github.com/microcosm-cc/bluemonday/releases)
- [Commits](https://github.com/microcosm-cc/bluemonday/compare/v1.0.25...v1.0.26)

---
updated-dependencies:
- dependency-name: github.com/microcosm-cc/bluemonday
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-02 17:15:20 +00:00
Matt Baer ae70c2dbe4
Merge pull request #810 from writefreely/dependabot/go_modules/golang.org/x/net-0.17.0
Bump golang.org/x/net from 0.15.0 to 0.17.0
2023-11-02 13:14:39 -04:00
dependabot[bot] cdb1ffd1da
Bump golang.org/x/net from 0.15.0 to 0.17.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.15.0 to 0.17.0.
- [Commits](https://github.com/golang/net/compare/v0.15.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-02 17:12:54 +00:00
Matt Baer d467fdf158
Merge pull request #809 from writefreely/dependabot/go_modules/golang.org/x/crypto-0.14.0
Bump golang.org/x/crypto from 0.13.0 to 0.14.0
2023-11-02 13:11:36 -04:00
dependabot[bot] 643d025381
Bump golang.org/x/crypto from 0.13.0 to 0.14.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.13.0 to 0.14.0.
- [Commits](https://github.com/golang/crypto/compare/v0.13.0...v0.14.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-01 22:45:31 +00:00
Kyra ee485e0488 Conditionally use twitter large summary card format when an image is available. 2023-10-25 17:15:05 -05:00
Matt Baer 5204b3b752
Merge pull request #782 from writefreely/verify-collection-max-lengths
Prevent 500 errors on too-long collection title or description
2023-10-23 12:50:06 -04:00
Matt Baer 45ca9c4c2b
Merge pull request #781 from writefreely/fix-updates-masto
Ensure Update activities work with Mastodon
2023-10-23 12:49:30 -04:00
Matt Baer 71fd25870d
Merge pull request #793 from writefreely/fix-fedi-followers
Add missing methods for showing fediverse followers
2023-10-23 12:48:32 -04:00
Matt Baer dd797c8145 Add missing methods for showing fediverse followers
Fixes #791
2023-10-13 16:45:12 -04:00
Matt Baer 3870749e5e
Merge pull request #785 from blujan/develop
Fix use of NOW() when getting tagged posts
2023-10-11 10:55:51 -04:00
Brennan Lujan 87b3585c44 Fix use of NOW() when getting tagged posts 2023-10-06 20:20:40 -07:00
Matt Baer bf213cd0b0 Fix drafts never showing, even when not part of private/protected blog 2023-10-06 12:40:46 -04:00
Matt Baer 815500ab78 Merge branch 'develop' into protect-drafts 2023-10-06 12:19:37 -04:00
Matt Baer 4aad0338bf
Merge pull request #779 from writefreely/fix-ld-json-response-2
Correctly respond to application/ld+json requests, part 2
2023-10-03 12:06:10 -04:00
Matt Baer 711cb387a5
Merge pull request #778 from writefreely/better-indexing
Add index to improve post retrieval speed on large instances
2023-10-03 12:04:20 -04:00
Matt Baer e3323d11c8
Merge pull request #777 from writefreely/reset-password
Support resetting password via email

Closes T508
2023-10-03 12:03:08 -04:00
Matt Baer 076c4ae2f2 Set blog title maxlength on Customize page 2023-10-03 11:57:42 -04:00
Matt Baer 530a36fc53 Prevent 500 errors on too-long collection title or description
This truncates long titles and descriptions to the maximum column length, so
we don't get errors back from MySQL.

Fixes #600
2023-10-03 11:55:52 -04:00
Matt Baer 8207a25fa9 Tweak style of "Forgot" link on login page 2023-10-03 11:39:41 -04:00
Matt Baer 7b84dafea7 Correctly return on /reset submission when email isn't configured 2023-10-03 11:28:24 -04:00
Matt Baer ed60aea39e Catch and log emailPasswordReset errors 2023-10-03 11:25:05 -04:00
Matt Baer 8f02449ee8 Show friendly message on /reset when password-based login is disabled 2023-10-03 11:19:47 -04:00
Matt Baer 1e37f60d50 Hide "Reset?" link on login page when email disabled 2023-10-03 11:16:11 -04:00
Matt Baer c18987705c Display friendly message on /reset if email is disabled 2023-10-03 11:15:33 -04:00
Matt Baer 7db4b699e2
Merge pull request #776 from writefreely/passwordless-login
Plumbing: login via emailed link

Ref T731
2023-10-03 11:02:30 -04:00
Matt Baer 26ba79ff02
Merge pull request #775 from writefreely/subscriber-insights
Add Subscribers page

Closes T826
2023-10-03 10:59:21 -04:00
Matt Baer b232e7efd7 Fix indentation in subscribers.tmpl 2023-10-03 10:56:23 -04:00
Matt Baer 64dcb56793
Merge pull request #478 from writefreely/letters
Support email subscriptions
2023-10-03 10:50:34 -04:00
Matt Baer 273267343a Ensure Updated property can be omitted
Now, the web-core pkg uses a pointer instead of a var, so we don't send
a zero time.Time value out via ActivityPub.
2023-10-02 21:35:23 -04:00
Matt Baer 27e82f0409
Merge pull request #774 from writefreely/fix-no-fonts
Fix fonts not getting applied on first load
2023-10-02 19:43:50 -04:00
Matt Baer 167971771e Send `updated` parameter with `Update` activities
Per the Mastodon docs, this ensures the activity correctly updates posts there.
https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
2023-10-02 19:33:03 -04:00
Matt Baer 2275a288b9 Correctly respond to application/ld+json requests, part 2
This finishes the work started in #766, ensuring that requests to
canonical URLs of blogs and posts (not just at their API endpoints)
respond correctly to `application/ld+json;...` requests.

Fully addresses issue #564
2023-09-26 14:46:35 -04:00
Matt Baer f96f8268f0 Add index to improve post retrieval speed on large instances
On an instance with millions of posts across all users, a single blog with
thousands of posts on it can take a long time to render. This adds an index
to the `posts` table to speed up the basic GetPosts query.

Run: `writefreely db migrate`

Closes #741
2023-09-26 14:36:34 -04:00
Matt Baer 74f3ded250
Merge pull request #545 from clarfonthey/editorconfig
Add editorconfig
2023-09-26 11:52:43 -04:00
Matt Baer c1609cdb90
Merge pull request #658 from jsoref/spelling
Spelling
2023-09-26 11:50:19 -04:00
Matt Baer e96e657430 Fix copyright notices with wrong company name 2023-09-25 19:07:06 -04:00
Matt Baer f404f7b928 Support resetting password via email
This adds a self-serve password reset page. Users can enter their username
and receive an email with a link that will let them create a new password.
If they've never set a password, it will send them a one-time login link
(building on #776) that will then take them to their Account Settings page.
If they don't have an email associated with their account, they'll be
instructed to contact the admin, so they can manually reset the password.

Includes changes to the stylesheet and database, so run:

    make ui
    writefreely db migrate

Closes T508
2023-09-25 18:48:14 -04:00
Matt Baer 7dda53146d Add function for logging in via emailed link
This doesn't add any user-facing behavior, but provides the basic functionality
to generate a one-time use token and email it to a user, so they can log in with
a link instead of a password.
2023-09-25 18:21:20 -04:00
Matt Baer e2fde518ca Fix GetTemporaryOneTimeAccessToken query for SQLite 2023-09-25 18:18:01 -04:00
Matt Baer c75507ca8f Add Subscribers navigation for single-user instances
Ref T826
2023-09-25 17:04:08 -04:00
Matt Baer 82e7dcd3f3 Add Subscribers page
- Shows all fediverse followers and email subscribers
- Shows number of email subscribers on Stats page
- Links to Subscribers page from Stats page

Requires running `make ui` to regenerate stylesheet.

Ref T826
2023-09-25 16:55:57 -04:00
Matt Baer 361c887e2c Revert "use font-display:optional to optimize web font loading"
This reverts commit 059f0d4c54.
2023-09-25 15:58:55 -04:00
Matt Baer 13ca890709
Merge pull request #768 from writefreely/dependabot/go_modules/github.com/writeas/web-core-1.6.0
Bump github.com/writeas/web-core from 1.5.0 to 1.6.0
2023-09-25 15:52:35 -04:00
Matt Baer c6323dba8c Clean up SQLite to-do 2023-09-25 15:38:57 -04:00
Matt Baer dcc6f036c6 Clean up commented-out code 2023-09-25 15:31:31 -04:00
Matt Baer d7d44cb4e1 Catch subscription confirmation email errors 2023-09-25 15:31:10 -04:00
Matt Baer 2a496bd000 Fix subscriber created query for SQLite 2023-09-25 15:30:39 -04:00
Matt Baer 15047b7288 Fix jobs query in SQLite 2023-09-25 15:30:05 -04:00
Matt Baer d1afa44a2e Use standard SetCollectionAttribute method for saving email sub settings 2023-09-25 15:29:23 -04:00
Matt Baer ac40b2f733 Fix publishjobs `id` column in SQLite
Previously, didn't auto-increment or populate
2023-09-25 14:51:28 -04:00
Matt Baer e2b2ba4577 Rename Letters config to Email in collection.tmpl 2023-09-25 14:28:37 -04:00
Matt Baer cc75be1eb5 Rename Letters [letters] config section to Email [email] 2023-09-25 14:26:41 -04:00
Matt Baer 221d0d7dbb Make letters (v13) migration compatible with SQLite 2023-09-25 14:25:24 -04:00
Matt Baer cc9705447d Re-add letters migration 2023-09-25 14:00:18 -04:00
Matt Baer 06968e7341 Merge branch 'develop' into letters 2023-09-25 13:59:46 -04:00
Matt Baer 62f9b2948e Exclude local static files from release build 2023-09-22 17:10:42 -04:00
dependabot[bot] 10a415a7ec
Bump github.com/writeas/web-core from 1.5.0 to 1.6.0
Bumps [github.com/writeas/web-core](https://github.com/writeas/web-core) from 1.5.0 to 1.6.0.
- [Commits](https://github.com/writeas/web-core/compare/v1.5.0...v1.6.0)

---
updated-dependencies:
- dependency-name: github.com/writeas/web-core
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-21 20:36:05 +00:00
Matt Baer 142c5d6cec Re-add ossl_legacy.cnf 2023-09-21 16:07:09 -04:00
Matt Baer 526db318c4 Merge branch 'develop' into letters 2023-09-21 16:03:13 -04:00
Josh Soref ea81e2c839 spelling: pattern
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-03-05 02:44:02 -05:00
Josh Soref 02fb079a9f spelling: optional
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-03-05 02:44:02 -05:00
Josh Soref 0746ec8567 spelling: modified
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-03-05 02:44:02 -05:00
Josh Soref 7e5d60043d spelling: miscellaneous
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-03-05 02:24:29 -05:00
Josh Soref af875b4d87 spelling: message
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-03-05 02:24:29 -05:00
Josh Soref 8dd7b40c02 spelling: javascript
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-03-05 02:24:29 -05:00
Josh Soref 8834253502 spelling: into
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-03-05 02:24:29 -05:00
Josh Soref 7feea370ed spelling: highlight
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-03-05 02:24:29 -05:00
Josh Soref 680f0d1e20 spelling: github
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-03-05 02:24:29 -05:00
Josh Soref bc53300e33 spelling: dynamic
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-03-05 02:24:29 -05:00
Josh Soref af0927cf5c spelling: consequences
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-03-05 02:24:29 -05:00
Matt Baer 118eb732f4 Merge branch 'develop' into letters 2023-01-08 11:49:57 -05:00
ltdk 0a19dc1ec2 Add editorconfig 2022-05-11 13:11:22 -04:00
Matt Baer c3ae4e6d3c Remove blog name in newsletter email subject
Originally requested on the forum:
https://discuss.write.as/t/minimize-subject-of-email-updates/3881
2022-03-29 13:00:53 -04:00
Isaac Su df7be46417 Protect drafts if they are part of a Private or Protected collection 2022-01-11 16:31:11 +11:00
Matt Baer fc8e209def Strip Markdown from Letter subjects
Ref T856
2021-08-10 18:05:24 -04:00
Matt Baer e963755393 Set 'To' addresses on Letter email after message is prepared
This works with mailgun.AddRecipientAndVariables, so we can safely send
emails to a large number of recipients beyond Mailgun's 1,000-recipient
limit.

Ref T856
2021-08-10 18:01:19 -04:00
Matt Baer 2288ccf2a2 Merge branch 'develop' into letters 2021-08-10 17:47:23 -04:00
Matt Baer 2ea235f0c4 Support email subscriptions (base)
This adds beginning email subscription functionality, with only MySQL support,
Mailgun support, and incomplete support for private instances. It includes
database changes, so run:

    writefreely db migrate

to use this feature.

Ref T856
2021-06-21 18:24:40 -04:00
55 changed files with 2093 additions and 153 deletions

View File

@ -1,2 +1 @@
Dockerfile
.git

10
.editorconfig Normal file
View File

@ -0,0 +1,10 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[*.go]
indent_style = tab

View File

@ -1,4 +1,4 @@
name: Build container image, publish as Github-package
name: Build container image, publish as GitHub-package
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by

View File

@ -1,13 +1,15 @@
# Build image
FROM golang:1.19-alpine as build
# SHA256 of golang:1.21-alpine3.18 linux/amd64
FROM golang@sha256:f475434ea2047a83e9ba02a1da8efc250fa6b2ed0e9e8e4eb8c5322ea6997795 as build
LABEL org.opencontainers.image.source=https://github.com/writefreely/writefreely
LABEL org.opencontainers.image.source="https://github.com/writefreely/writefreely"
LABEL org.opencontainers.image.description="WriteFreely is a clean, minimalist publishing platform made for writers. Start a blog, share knowledge within your organization, or build a community around the shared act of writing."
RUN apk add --update nodejs npm make g++ git
RUN npm install -g less less-plugin-clean-css
RUN apk -U upgrade \
&& apk add --no-cache nodejs npm make g++ git \
&& npm install -g less less-plugin-clean-css \
&& mkdir -p /go/src/github.com/writefreely/writefreely
RUN mkdir -p /go/src/github.com/writefreely/writefreely
WORKDIR /go/src/github.com/writefreely/writefreely
COPY . .
@ -18,9 +20,9 @@ ENV GO111MODULE=on
ENV NODE_OPTIONS=--openssl-legacy-provider
RUN make build \
&& make ui
RUN mkdir /stage && \
cp -R /go/bin \
&& make ui \
&& mkdir /stage \
&& cp -R /go/bin \
/go/src/github.com/writefreely/writefreely/templates \
/go/src/github.com/writefreely/writefreely/static \
/go/src/github.com/writefreely/writefreely/pages \
@ -29,9 +31,12 @@ RUN mkdir /stage && \
/stage
# Final image
FROM alpine:3
# SHA256 of alpine:3.18.4 linux/amd64
FROM alpine@sha256:48d9183eb12a05c99bcc0bf44a003607b8e941e1d4f41f9ad12bdcc4b5672f86
RUN apk -U upgrade \
&& apk add --no-cache openssl ca-certificates
RUN apk add --no-cache openssl ca-certificates
COPY --from=build --chown=daemon:daemon /stage /go
WORKDIR /go
@ -40,3 +45,6 @@ EXPOSE 8080
USER daemon
ENTRYPOINT ["cmd/writefreely/writefreely"]
HEALTHCHECK --start-period=5s --interval=15s --timeout=5s \
CMD curl -fSs http://localhost:8080/ || exit 1

View File

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

View File

@ -13,6 +13,8 @@ package writefreely
import (
"encoding/json"
"fmt"
"github.com/mailgun/mailgun-go"
"github.com/writefreely/writefreely/spam"
"html/template"
"net/http"
"regexp"
@ -323,6 +325,7 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
To string
Message template.HTML
Flashes []template.HTML
EmailEnabled bool
LoginUsername string
}{
StaticPage: pageForReq(app, r),
@ -330,6 +333,7 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
To: r.FormValue("to"),
Message: template.HTML(""),
Flashes: []template.HTML{},
EmailEnabled: app.cfg.Email.Enabled(),
LoginUsername: getTempInfo(app, "login-user", r, w),
}
@ -504,7 +508,7 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error {
// User has no email set, so check if they haven't added a password, either,
// so we can return a more helpful error message.
if hasPass, _ := app.db.IsUserPassSet(u.ID); !hasPass {
log.Info("Tried logging in to %s, but no password or email.", signin.Alias)
log.Info("Tried logging into %s, but no password or email.", signin.Alias)
return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."}
}
}
@ -577,7 +581,7 @@ func getVerboseAuthUser(app *App, token string, u *User, verbose bool) *AuthUser
}
passIsSet, err := app.db.IsUserPassSet(u.ID)
if err != nil {
// TODO: correct error meesage
// TODO: correct error message
log.Error("Login: Unable to get user collections: %v", err)
}
@ -875,12 +879,19 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
*UserPage
*Collection
Silenced bool
config.EmailCfg
LetterReplyTo string
}{
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
Collection: c,
Silenced: silenced,
EmailCfg: app.cfg.Email,
}
obj.UserPage.CollAlias = c.Alias
if obj.EmailCfg.Enabled() {
obj.LetterReplyTo = app.db.GetCollectionAttribute(c.ID, collAttrLetterReplyTo)
}
showUserPage(w, "collection", obj)
return nil
@ -1052,17 +1063,20 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
}
obj := struct {
*UserPage
VisitsBlog string
Collection *Collection
TopPosts *[]PublicPost
APFollowers int
Silenced bool
VisitsBlog string
Collection *Collection
TopPosts *[]PublicPost
APFollowers int
EmailEnabled bool
EmailSubscribers int
Silenced bool
}{
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
VisitsBlog: alias,
Collection: c,
TopPosts: topPosts,
Silenced: silenced,
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
VisitsBlog: alias,
Collection: c,
TopPosts: topPosts,
EmailEnabled: app.cfg.Email.Enabled(),
Silenced: silenced,
}
obj.UserPage.CollAlias = c.Alias
if app.cfg.App.Federation {
@ -1072,11 +1086,73 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
}
obj.APFollowers = len(*folls)
}
if obj.EmailEnabled {
subs, err := app.db.GetEmailSubscribers(c.ID, true)
if err != nil {
return err
}
obj.EmailSubscribers = len(subs)
}
showUserPage(w, "stats", obj)
return nil
}
func handleViewSubscribers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
c, err := app.db.GetCollection(vars["collection"])
if err != nil {
return err
}
filter := r.FormValue("filter")
flashes, _ := getSessionFlashes(app, w, r, nil)
obj := struct {
*UserPage
Collection CollectionNav
EmailSubs []*EmailSubscriber
Followers *[]RemoteUser
Silenced bool
Filter string
FederationEnabled bool
CanEmailSub bool
CanAddSubs bool
EmailSubsEnabled bool
}{
UserPage: NewUserPage(app, r, u, c.DisplayTitle()+" Subscribers", flashes),
Collection: CollectionNav{
Collection: c,
Path: r.URL.Path,
SingleUser: app.cfg.App.SingleUser,
},
Silenced: u.IsSilenced(),
Filter: filter,
FederationEnabled: app.cfg.App.Federation,
CanEmailSub: app.cfg.Email.Enabled(),
EmailSubsEnabled: c.EmailSubsEnabled(),
}
obj.Followers, err = app.db.GetAPFollowers(c)
if err != nil {
return err
}
obj.EmailSubs, err = app.db.GetEmailSubscribers(c.ID, true)
if err != nil {
return err
}
if obj.Filter == "" {
// Set permission to add email subscribers
//obj.CanAddSubs = app.db.GetUserAttribute(c.OwnerID, userAttrCanAddEmailSubs) == "1"
}
showUserPage(w, "subscribers", obj)
return nil
}
func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
fullUser, err := app.db.GetUserByID(u.ID)
if err != nil {
@ -1165,6 +1241,211 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err
return nil
}
func viewResetPassword(app *App, w http.ResponseWriter, r *http.Request) error {
token := r.FormValue("t")
resetting := false
var userID int64 = 0
if token != "" {
// Show new password page
userID = app.db.GetUserFromPasswordReset(token)
if userID == 0 {
return impart.HTTPError{http.StatusNotFound, ""}
}
resetting = true
}
if r.Method == http.MethodPost {
newPass := r.FormValue("new-pass")
if newPass == "" {
// Send password reset email
return handleResetPasswordInit(app, w, r)
}
// Do actual password reset
// Assumes token has been validated above
err := doAutomatedPasswordChange(app, userID, newPass)
if err != nil {
return err
}
err = app.db.ConsumePasswordResetToken(token)
if err != nil {
log.Error("Couldn't consume token %s for user %d!!! %s", token, userID, err)
}
addSessionFlash(app, w, r, "Your password was reset. Now you can log in below.", nil)
return impart.HTTPError{http.StatusFound, "/login"}
}
f, _ := getSessionFlashes(app, w, r, nil)
// Show reset password page
d := struct {
page.StaticPage
Flashes []string
EmailEnabled bool
CSRFField template.HTML
Token string
IsResetting bool
IsSent bool
}{
StaticPage: pageForReq(app, r),
Flashes: f,
EmailEnabled: app.cfg.Email.Enabled(),
CSRFField: csrf.TemplateField(r),
Token: token,
IsResetting: resetting,
IsSent: r.FormValue("sent") == "1",
}
err := pages["reset.tmpl"].ExecuteTemplate(w, "base", d)
if err != nil {
log.Error("Unable to render password reset page: %v", err)
return err
}
return err
}
func doAutomatedPasswordChange(app *App, userID int64, newPass string) error {
// Do password reset
hashedPass, err := auth.HashPass([]byte(newPass))
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
}
// Do update
err = app.db.ChangePassphrase(userID, true, "", hashedPass)
if err != nil {
return err
}
return nil
}
func handleResetPasswordInit(app *App, w http.ResponseWriter, r *http.Request) error {
returnLoc := impart.HTTPError{http.StatusFound, "/reset"}
if !app.cfg.Email.Enabled() {
// Email isn't configured, so there's nothing to do; send back to the reset form, where they'll get an explanation
return returnLoc
}
ip := spam.GetIP(r)
alias := r.FormValue("alias")
u, err := app.db.GetUserForAuth(alias)
if err != nil {
if strings.IndexAny(alias, "@") > 0 {
addSessionFlash(app, w, r, ErrUserNotFoundEmail.Message, nil)
return returnLoc
}
addSessionFlash(app, w, r, ErrUserNotFound.Message, nil)
return returnLoc
}
if u.IsAdmin() {
// Prevent any reset emails on admin accounts
log.Error("Admin reset attempt", `Someone just tried to reset the password for an admin (ID %d - %s). IP address: %s`, u.ID, u.Username, ip)
return returnLoc
}
if u.Email.String == "" {
err := impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Please contact us (" + app.cfg.App.Host + "/contact) to reset your password."}
addSessionFlash(app, w, r, err.Message, nil)
return returnLoc
}
if isSet, _ := app.db.IsUserPassSet(u.ID); !isSet {
err = loginViaEmail(app, u.Username, "/me/settings")
if err != nil {
return err
}
addSessionFlash(app, w, r, "We've emailed you a link to log in with.", nil)
return returnLoc
}
token, err := app.db.CreatePasswordResetToken(u.ID)
if err != nil {
log.Error("Error resetting password: %s", err)
addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil)
return returnLoc
}
err = emailPasswordReset(app, u.EmailClear(app.keys), token)
if err != nil {
log.Error("Error emailing password reset: %s", err)
addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil)
return returnLoc
}
addSessionFlash(app, w, r, "We sent an email to the address associated with this account.", nil)
returnLoc.Message += "?sent=1"
return returnLoc
}
func emailPasswordReset(app *App, toEmail, token string) error {
// Send email
gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate)
footerPara := "Didn't request this password reset? Your account is still safe, and you can safely ignore this email."
plainMsg := fmt.Sprintf("We received a request to reset your password on %s. Please click the following link to continue (or copy and paste it into your browser): %s/reset?t=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara)
m := mailgun.NewMessage(app.cfg.App.SiteName+" <noreply-password@"+app.cfg.Email.Domain+">", "Reset Your "+app.cfg.App.SiteName+" Password", plainMsg, fmt.Sprintf("<%s>", toEmail))
m.AddTag("Password Reset")
m.SetHtml(fmt.Sprintf(`<html>
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;">
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1>
<p>We received a request to reset your password on %s. Please click the following link to continue:</p>
<p style="font-size:1.2em;margin-bottom:1.5em;"><a href="%s/reset?t=%s">Reset your password</a></p>
<p style="font-size: 0.86em;margin:1em auto">%s</p>
</div>
</body>
</html>`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara))
_, _, err := gun.Send(m)
return err
}
func loginViaEmail(app *App, alias, redirectTo string) error {
if !app.cfg.Email.Enabled() {
return fmt.Errorf("EMAIL ISN'T CONFIGURED on this server")
}
// Make sure user has added an email
// TODO: create a new func to just get user's email; "ForAuth" doesn't match here
u, _ := app.db.GetUserForAuth(alias)
if u == nil {
if strings.IndexAny(alias, "@") > 0 {
return ErrUserNotFoundEmail
}
return ErrUserNotFound
}
if u.Email.String == "" {
return impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Log in with password, instead."}
}
// Generate one-time login token
t, err := app.db.GetTemporaryOneTimeAccessToken(u.ID, 60*15, true)
if err != nil {
log.Error("Unable to generate token for email login: %s", err)
return impart.HTTPError{http.StatusInternalServerError, "Unable to generate token."}
}
// Send email
gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate)
toEmail := u.EmailClear(app.keys)
footerPara := "This link will only work once and expires in 15 minutes. Didn't ask us to log in? You can safely ignore this email."
plainMsg := fmt.Sprintf("Log in to %s here: %s/login?to=%s&with=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, footerPara)
m := mailgun.NewMessage(app.cfg.App.SiteName+" <noreply-login@"+app.cfg.Email.Domain+">", "Log in to "+app.cfg.App.SiteName, plainMsg, fmt.Sprintf("<%s>", toEmail))
m.AddTag("Email Login")
m.SetHtml(fmt.Sprintf(`<html>
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;">
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1>
<p style="font-size:1.2em;margin-bottom:1.5em;text-align:center"><a href="%s/login?to=%s&with=%s">Log in to %s here</a>.</p>
<p style="font-size: 0.86em;color:#666;text-align:center;max-width:35em;margin:1em auto">%s</p>
</div>
</body>
</html>`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, app.cfg.App.SiteName, footerPara))
_, _, err = gun.Send(m)
return err
}
func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error {
session, err := app.sessionStore.Get(r, "t")
if err != nil {

View File

@ -65,6 +65,20 @@ type RemoteUser struct {
SharedInbox string
URL string
Handle string
Created time.Time
}
func (ru *RemoteUser) CreatedFriendly() string {
return ru.Created.Format("January 2, 2006")
}
func (ru *RemoteUser) EstimatedHandle() string {
if ru.Handle != "" {
return ru.Handle
}
username := filepath.Base(ru.ActorID)
host, _ := url.Parse(ru.ActorID)
return username + "@" + host.Host
}
func (ru *RemoteUser) AsPerson() *activitystreams.Person {
@ -718,6 +732,7 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
// create a new "Create" activity
// with our article as object
if isUpdate {
na.Updated = &p.Updated
activity = activitystreams.NewUpdateActivity(na)
} else {
activity = activitystreams.NewCreateActivity(na)

15
app.go
View File

@ -58,7 +58,7 @@ var (
debugging bool
// Software version can be set from git env using -ldflags
softwareVer = "0.14.0"
softwareVer = "0.15.0"
// DEPRECATED VARS
isSingleUser bool
@ -365,7 +365,7 @@ func pageForReq(app *App, r *http.Request) page.StaticPage {
}
// Use custom style, if file exists
if _, err := os.Stat(filepath.Join(staticDir, "local", "custom.css")); err == nil {
if _, err := os.Stat(filepath.Join(app.cfg.Server.StaticParentDir, staticDir, "local", "custom.css")); err == nil {
p.CustomCSS = true
}
@ -428,6 +428,17 @@ func Initialize(apper Apper, debug bool) (*App, error) {
initActivityPub(apper.App())
if apper.App().cfg.Email.Domain != "" || apper.App().cfg.Email.MailgunPrivate != "" {
if apper.App().cfg.Email.Domain == "" {
log.Error("[FAILED] Starting publish jobs queue: no [letters]domain config value set.")
} else if apper.App().cfg.Email.MailgunPrivate == "" {
log.Error("[FAILED] Starting publish jobs queue: no [letters]mailgun_private config value set.")
} else {
log.Info("Starting publish jobs queue...")
go startPublishJobsQueue(apper.App())
}
}
// Handle local timeline, if enabled
if apper.App().cfg.App.LocalTimeline {
log.Info("Initializing local timeline...")

View File

@ -35,9 +35,17 @@ import (
"github.com/writefreely/writefreely/author"
"github.com/writefreely/writefreely/config"
"github.com/writefreely/writefreely/page"
"github.com/writefreely/writefreely/spam"
"golang.org/x/net/idna"
)
const (
collAttrLetterReplyTo = "letter_reply_to"
collMaxLengthTitle = 255
collMaxLengthDescription = 160
)
type (
// TODO: add Direction to db
// TODO: add Language to db
@ -81,6 +89,14 @@ type (
TotalPages int
Silenced bool
}
CollectionNav struct {
*Collection
Path string
SingleUser bool
CanPost bool
}
SubmittedCollection struct {
// Data used for updating a given collection
ID int64
@ -91,17 +107,19 @@ type (
Privacy int `schema:"privacy" json:"privacy"`
Pass string `schema:"password" json:"password"`
MathJax bool `schema:"mathjax" json:"mathjax"`
EmailSubs bool `schema:"email_subs" json:"email_subs"`
Handle string `schema:"handle" json:"handle"`
// Actual collection values updated in the DB
Alias *string `schema:"alias" json:"alias"`
Title *string `schema:"title" json:"title"`
Description *string `schema:"description" json:"description"`
StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"`
Script *sql.NullString `schema:"script" json:"script"`
Signature *sql.NullString `schema:"signature" json:"signature"`
StyleSheet *string `schema:"style_sheet" json:"style_sheet"`
Script *string `schema:"script" json:"script"`
Signature *string `schema:"signature" json:"signature"`
Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"`
Verification *string `schema:"verification_link" json:"verification_link"`
LetterReply *string `schema:"letter_reply" json:"letter_reply"`
Visibility *int `schema:"visibility" json:"public"`
Format *sql.NullString `schema:"format" json:"format"`
}
@ -361,6 +379,10 @@ func (c *Collection) RenderMathJax() bool {
return c.db.CollectionHasAttribute(c.ID, "render_mathjax")
}
func (c *Collection) EmailSubsEnabled() bool {
return c.db.CollectionHasAttribute(c.ID, "email_subs")
}
func (c *Collection) MonetizationURL() string {
if c.Monetization == "" {
return ""
@ -612,13 +634,17 @@ type CollectionPage struct {
IsWelcome bool
IsOwner bool
IsCollLoggedIn bool
Honeypot string
IsSubscriber bool
CanPin bool
Username string
Monetization string
Flash template.HTML
Collections *[]Collection
PinnedPosts *[]PublicPost
IsAdmin bool
CanInvite bool
IsAdmin bool
CanInvite bool
// Helper field for Chorus mode
CollAlias string
@ -853,7 +879,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
}
// Serve ActivityStreams data now, if requested
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
if IsActivityPubRequest(r) {
ac := c.PersonObject()
ac.Context = []interface{}{activitystreams.Namespace}
setCacheControl(w, apCacheTime)
@ -882,14 +908,20 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
StaticPage: pageForReq(app, r),
IsCustomDomain: cr.isCustomDomain,
IsWelcome: r.FormValue("greeting") != "",
Honeypot: spam.HoneypotFieldName(),
CollAlias: c.Alias,
}
flashes, _ := getSessionFlashes(app, w, r, nil)
for _, f := range flashes {
displayPage.Flash = template.HTML(f)
}
displayPage.IsAdmin = u != nil && u.IsAdmin()
displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin)
var owner *User
if u != nil {
displayPage.Username = u.Username
displayPage.IsOwner = u.ID == coll.OwnerID
displayPage.IsSubscriber = u.IsEmailSubscriber(app, coll.ID)
if displayPage.IsOwner {
// Add in needed information for users viewing their own collection
owner = u

View File

@ -170,11 +170,17 @@ type (
DisablePasswordAuth bool `ini:"disable_password_auth"`
}
EmailCfg struct {
Domain string `ini:"domain"`
MailgunPrivate string `ini:"mailgun_private"`
}
// Config holds the complete configuration for running a writefreely instance
Config struct {
Server ServerCfg `ini:"server"`
Database DatabaseCfg `ini:"database"`
App AppCfg `ini:"app"`
Email EmailCfg `ini:"email"`
SlackOauth SlackOauthCfg `ini:"oauth.slack"`
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"`
@ -235,6 +241,10 @@ func (ac *AppCfg) LandingPath() string {
return ac.Landing
}
func (lc EmailCfg) Enabled() bool {
return lc.Domain != "" && lc.MailgunPrivate != ""
}
func (ac AppCfg) SignupPath() string {
if !ac.OpenRegistration {
return ""

View File

@ -14,13 +14,16 @@ import (
"context"
"database/sql"
"fmt"
"github.com/writeas/web-core/silobridge"
wf_db "github.com/writefreely/writefreely/db"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"github.com/writeas/web-core/silobridge"
wf_db "github.com/writefreely/writefreely/db"
"github.com/writefreely/writefreely/parse"
"github.com/guregu/null"
"github.com/guregu/null/zero"
uuid "github.com/nu7hatch/gouuid"
@ -173,6 +176,13 @@ func (db *datastore) upsert(indexedCols ...string) string {
return "ON DUPLICATE KEY UPDATE"
}
func (db *datastore) dateAdd(l int, unit string) string {
if db.driverName == driverSQLite {
return fmt.Sprintf("DATETIME('now', '%d %s')", l, unit)
}
return fmt.Sprintf("DATE_ADD(NOW(), INTERVAL %d %s)", l, unit)
}
func (db *datastore) dateSub(l int, unit string) string {
if db.driverName == driverSQLite {
return fmt.Sprintf("DATETIME('now', '-%d %s')", l, unit)
@ -566,7 +576,7 @@ func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int,
expirationVal := "NULL"
if validSecs > 0 {
expirationVal = fmt.Sprintf("DATE_ADD("+db.now()+", INTERVAL %d SECOND)", validSecs)
expirationVal = db.dateAdd(validSecs, "SECOND")
}
_, err = db.Exec("INSERT INTO accesstokens (token, user_id, one_time, expires) VALUES (?, ?, ?, "+expirationVal+")", string(binTok), userID, oneTime)
@ -578,6 +588,37 @@ func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int,
return u.String(), nil
}
func (db *datastore) CreatePasswordResetToken(userID int64) (string, error) {
t := id.Generate62RandomString(32)
_, err := db.Exec("INSERT INTO password_resets (user_id, token, used, created) VALUES (?, ?, 0, "+db.now()+")", userID, t)
if err != nil {
log.Error("Couldn't INSERT password_resets: %v", err)
return "", err
}
return t, nil
}
func (db *datastore) GetUserFromPasswordReset(token string) int64 {
var userID int64
err := db.QueryRow("SELECT user_id FROM password_resets WHERE token = ? AND used = 0 AND created > "+db.dateSub(3, "HOUR"), token).Scan(&userID)
if err != nil {
return 0
}
return userID
}
func (db *datastore) ConsumePasswordResetToken(t string) error {
_, err := db.Exec("UPDATE password_resets SET used = 1 WHERE token = ?", t)
if err != nil {
log.Error("Couldn't UPDATE password_resets: %v", err)
return err
}
return nil
}
func (db *datastore) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName string) (*PublicPost, error) {
var userID, collID int64 = -1, -1
var coll *Collection
@ -854,12 +895,20 @@ func (db *datastore) GetCollectionFromDomain(host string) (*Collection, error) {
}
func (db *datastore) UpdateCollection(app *App, c *SubmittedCollection, alias string) error {
// Truncate fields correctly, so we don't get "Data too long for column" errors in MySQL (writefreely#600)
if c.Title != nil {
*c.Title = parse.Truncate(*c.Title, collMaxLengthTitle)
}
if c.Description != nil {
*c.Description = parse.Truncate(*c.Description, collMaxLengthDescription)
}
q := query.NewUpdate().
SetStringPtr(c.Title, "title").
SetStringPtr(c.Description, "description").
SetNullString(c.StyleSheet, "style_sheet").
SetNullString(c.Script, "script").
SetNullString(c.Signature, "post_signature")
SetStringPtr(c.StyleSheet, "style_sheet").
SetStringPtr(c.Script, "script").
SetStringPtr(c.Signature, "post_signature")
if c.Format != nil {
cf := &CollectionFormat{Format: c.Format.String}
@ -973,6 +1022,40 @@ func (db *datastore) UpdateCollection(app *App, c *SubmittedCollection, alias st
}
}
// Update EmailSub value
if c.EmailSubs {
err = db.SetCollectionAttribute(collID, "email_subs", "1")
if err != nil {
log.Error("Unable to insert email_subs value: %v", err)
return err
}
skipUpdate := false
if c.LetterReply != nil {
// Strip away any excess spaces
trimmed := strings.TrimSpace(*c.LetterReply)
// Only update value when it contains "@"
if strings.IndexRune(trimmed, '@') > 0 {
c.LetterReply = &trimmed
} else {
// Value appears invalid, so don't update
skipUpdate = true
}
if !skipUpdate {
err = db.SetCollectionAttribute(collID, collAttrLetterReplyTo, *c.LetterReply)
if err != nil {
log.Error("Unable to insert %s value: %v", collAttrLetterReplyTo, err)
return err
}
}
}
} else {
_, err = db.Exec("DELETE FROM collectionattributes WHERE collection_id = ? AND attribute = ?", collID, "email_subs")
if err != nil {
log.Error("Unable to delete email_subs value: %v", err)
return err
}
}
// Update rest of the collection data
if q.Updates != "" {
res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...)
@ -1247,7 +1330,7 @@ func (db *datastore) GetAllPostsTaggedIDs(c *Collection, tag string, includeFutu
timeCondition := ""
if !includeFuture {
timeCondition = "AND created <= NOW()"
timeCondition = "AND created <= " + db.now()
}
var rows *sql.Rows
var err error
@ -1415,7 +1498,7 @@ ORDER BY created `+order+limitStr, collID, lang)
}
func (db *datastore) GetAPFollowers(c *Collection) (*[]RemoteUser, error) {
rows, err := db.Query("SELECT actor_id, inbox, shared_inbox FROM remotefollows f INNER JOIN remoteusers u ON f.remote_user_id = u.id WHERE collection_id = ?", c.ID)
rows, err := db.Query("SELECT actor_id, inbox, shared_inbox, f.created FROM remotefollows f INNER JOIN remoteusers u ON f.remote_user_id = u.id WHERE collection_id = ?", c.ID)
if err != nil {
log.Error("Failed selecting from followers: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve followers."}
@ -1425,7 +1508,7 @@ func (db *datastore) GetAPFollowers(c *Collection) (*[]RemoteUser, error) {
followers := []RemoteUser{}
for rows.Next() {
f := RemoteUser{}
err = rows.Scan(&f.ActorID, &f.Inbox, &f.SharedInbox)
err = rows.Scan(&f.ActorID, &f.Inbox, &f.SharedInbox, &f.Created)
followers = append(followers, f)
}
return &followers, nil
@ -2968,3 +3051,247 @@ func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string,
}
return actorIRI, nil
}
func (db *datastore) AddEmailSubscription(collID, userID int64, email string, confirmed bool) (*EmailSubscriber, error) {
friendlyChars := "0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz"
subID := id.GenerateRandomString(friendlyChars, 8)
token := id.GenerateRandomString(friendlyChars, 16)
emailVal := sql.NullString{
String: email,
Valid: email != "",
}
userIDVal := sql.NullInt64{
Int64: userID,
Valid: userID > 0,
}
_, err := db.Exec("INSERT INTO emailsubscribers (id, collection_id, user_id, email, subscribed, token, confirmed) VALUES (?, ?, ?, ?, "+db.now()+", ?, ?)", subID, collID, userIDVal, emailVal, token, confirmed)
if err != nil {
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
if mysqlErr.Number == mySQLErrDuplicateKey {
// Duplicate, so just return existing subscriber information
log.Info("Duplicate subscriber for email %s, user %d; returning existing subscriber", email, userID)
return db.FetchEmailSubscriber(email, userID, collID)
}
}
return nil, err
}
return &EmailSubscriber{
ID: subID,
CollID: collID,
UserID: userIDVal,
Email: emailVal,
Token: token,
}, nil
}
func (db *datastore) IsEmailSubscriber(email string, userID, collID int64) bool {
var dummy int
var err error
if email != "" {
err = db.QueryRow("SELECT 1 FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID).Scan(&dummy)
} else {
err = db.QueryRow("SELECT 1 FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID).Scan(&dummy)
}
switch {
case err == sql.ErrNoRows:
return false
case err != nil:
return false
}
return true
}
func (db *datastore) GetEmailSubscribers(collID int64, reqConfirmed bool) ([]*EmailSubscriber, error) {
cond := ""
if reqConfirmed {
cond = " AND confirmed = 1"
}
rows, err := db.Query(`SELECT s.id, collection_id, user_id, s.email, u.email, subscribed, token, confirmed, allow_export
FROM emailsubscribers s
LEFT JOIN users u
ON u.id = user_id
WHERE collection_id = ?`+cond+`
ORDER BY subscribed DESC`, collID)
if err != nil {
log.Error("Failed selecting email subscribers for collection %d: %v", collID, err)
return nil, err
}
defer rows.Close()
var subs []*EmailSubscriber
for rows.Next() {
s := &EmailSubscriber{}
err = rows.Scan(&s.ID, &s.CollID, &s.UserID, &s.Email, &s.acctEmail, &s.Subscribed, &s.Token, &s.Confirmed, &s.AllowExport)
if err != nil {
log.Error("Failed scanning row from email subscribers: %v", err)
continue
}
subs = append(subs, s)
}
return subs, nil
}
func (db *datastore) FetchEmailSubscriberEmail(subID, token string) (string, error) {
var email sql.NullString
// TODO: return user email if there's a user_id ?
err := db.QueryRow("SELECT email FROM emailsubscribers WHERE id = ? AND token = ?", subID, token).Scan(&email)
switch {
case err == sql.ErrNoRows:
return "", fmt.Errorf("Subscriber doesn't exist or token is invalid.")
case err != nil:
log.Error("Couldn't SELECT email from emailsubscribers: %v", err)
return "", fmt.Errorf("Something went very wrong.")
}
return email.String, nil
}
func (db *datastore) FetchEmailSubscriber(email string, userID, collID int64) (*EmailSubscriber, error) {
const emailSubCols = "id, collection_id, user_id, email, subscribed, token, confirmed, allow_export"
s := &EmailSubscriber{}
var row *sql.Row
if email != "" {
row = db.QueryRow("SELECT "+emailSubCols+" FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID)
} else {
row = db.QueryRow("SELECT "+emailSubCols+" FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID)
}
err := row.Scan(&s.ID, &s.CollID, &s.UserID, &s.Email, &s.Subscribed, &s.Token, &s.Confirmed, &s.AllowExport)
switch {
case err == sql.ErrNoRows:
return nil, nil
case err != nil:
return nil, err
}
return s, nil
}
func (db *datastore) DeleteEmailSubscriber(subID, token string) error {
res, err := db.Exec("DELETE FROM emailsubscribers WHERE id = ? AND token = ?", subID, token)
if err != nil {
return err
}
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 0 {
return impart.HTTPError{http.StatusNotFound, "Invalid token, or subscriber doesn't exist"}
}
return nil
}
func (db *datastore) DeleteEmailSubscriberByUser(email string, userID, collID int64) error {
var res sql.Result
var err error
if email != "" {
res, err = db.Exec("DELETE FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID)
} else {
res, err = db.Exec("DELETE FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID)
}
if err != nil {
return err
}
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 0 {
return impart.HTTPError{http.StatusNotFound, "Subscriber doesn't exist"}
}
return nil
}
func (db *datastore) UpdateSubscriberConfirmed(subID, token string) error {
email, err := db.FetchEmailSubscriberEmail(subID, token)
if err != nil {
log.Error("Didn't fetch email subscriber: %v", err)
return err
}
// TODO: ensure all addresses with original name are also confirmed, e.g. matt+fake@write.as and matt@write.as are now confirmed
_, err = db.Exec("UPDATE emailsubscribers SET confirmed = 1 WHERE email = ?", email)
if err != nil {
log.Error("Could not update email subscriber confirmation status: %v", err)
return err
}
return nil
}
func (db *datastore) IsSubscriberConfirmed(email string) bool {
var dummy int64
err := db.QueryRow("SELECT 1 FROM emailsubscribers WHERE email = ? AND confirmed = 1", email).Scan(&dummy)
switch {
case err == sql.ErrNoRows:
return false
case err != nil:
log.Error("Couldn't SELECT in isSubscriberConfirmed: %v", err)
return false
}
return true
}
func (db *datastore) InsertJob(j *PostJob) error {
res, err := db.Exec("INSERT INTO publishjobs (post_id, action, delay) VALUES (?, ?, ?)", j.PostID, j.Action, j.Delay)
if err != nil {
return err
}
jobID, err := res.LastInsertId()
if err != nil {
log.Error("[jobs] Couldn't get last insert ID! %s", err)
}
log.Info("[jobs] Queued %s job #%d for post %s, delayed %d minutes", j.Action, jobID, j.PostID, j.Delay)
return nil
}
func (db *datastore) UpdateJobForPost(postID string, delay int64) error {
_, err := db.Exec("UPDATE publishjobs SET delay = ? WHERE post_id = ?", delay, postID)
if err != nil {
return fmt.Errorf("Unable to update publish job: %s", err)
}
log.Info("Updated job for post %s: delay %d", postID, delay)
return nil
}
func (db *datastore) DeleteJob(id int64) error {
_, err := db.Exec("DELETE FROM publishjobs WHERE id = ?", id)
if err != nil {
return err
}
log.Info("[job #%d] Deleted.", id)
return nil
}
func (db *datastore) DeleteJobByPost(postID string) error {
_, err := db.Exec("DELETE FROM publishjobs WHERE post_id = ?", postID)
if err != nil {
return err
}
log.Info("[job] Deleted job for post %s", postID)
return nil
}
func (db *datastore) GetJobsToRun(action string) ([]*PostJob, error) {
timeWhere := "created < DATE_SUB(NOW(), INTERVAL delay MINUTE) AND created > DATE_SUB(NOW(), INTERVAL delay + 5 MINUTE)"
if db.driverName == driverSQLite {
timeWhere = "created < DATETIME('now', '-' || delay || ' MINUTE') AND created > DATETIME('now', '-' || (delay+5) || ' MINUTE')"
}
rows, err := db.Query(`SELECT pj.id, post_id, action, delay
FROM publishjobs pj
INNER JOIN posts p
ON post_id = p.id
WHERE action = ? AND `+timeWhere+`
ORDER BY created ASC`, action)
if err != nil {
log.Error("Failed selecting from publishjobs: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve publish jobs."}
}
defer rows.Close()
jobs := []*PostJob{}
for rows.Next() {
j := &PostJob{}
err = rows.Scan(&j.ID, &j.PostID, &j.Action, &j.Delay)
jobs = append(jobs, j)
}
return jobs, nil
}

462
email.go Normal file
View File

@ -0,0 +1,462 @@
/*
* Copyright © 2019-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"
"encoding/json"
"fmt"
"html/template"
"net/http"
"strings"
"time"
"github.com/aymerick/douceur/inliner"
"github.com/gorilla/mux"
"github.com/mailgun/mailgun-go"
stripmd "github.com/writeas/go-strip-markdown/v2"
"github.com/writeas/impart"
"github.com/writeas/web-core/data"
"github.com/writeas/web-core/log"
"github.com/writefreely/writefreely/key"
"github.com/writefreely/writefreely/spam"
)
const (
emailSendDelay = 15
)
type (
SubmittedSubscription struct {
CollAlias string
UserID int64
Email string `schema:"email" json:"email"`
Web bool `schema:"web" json:"web"`
Slug string `schema:"slug" json:"slug"`
From string `schema:"from" json:"from"`
}
EmailSubscriber struct {
ID string
CollID int64
UserID sql.NullInt64
Email sql.NullString
Subscribed time.Time
Token string
Confirmed bool
AllowExport bool
acctEmail sql.NullString
}
)
func (es *EmailSubscriber) FinalEmail(keys *key.Keychain) string {
if !es.UserID.Valid || es.Email.Valid {
return es.Email.String
}
decEmail, err := data.Decrypt(keys.EmailKey, []byte(es.acctEmail.String))
if err != nil {
log.Error("Error decrypting user email: %v", err)
return ""
}
return string(decEmail)
}
func (es *EmailSubscriber) SubscribedFriendly() string {
return es.Subscribed.Format("January 2, 2006")
}
func handleCreateEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r)
vars := mux.Vars(r)
var err error
ss := SubmittedSubscription{
CollAlias: vars["alias"],
}
u := getUserSession(app, r)
if u != nil {
ss.UserID = u.ID
}
if reqJSON {
// Decode JSON request
decoder := json.NewDecoder(r.Body)
err = decoder.Decode(&ss)
if err != nil {
log.Error("Couldn't parse new subscription JSON request: %v\n", err)
return ErrBadJSON
}
} else {
err = r.ParseForm()
if err != nil {
log.Error("Couldn't parse new subscription form request: %v\n", err)
return ErrBadFormData
}
err = app.formDecoder.Decode(&ss, r.PostForm)
if err != nil {
log.Error("Continuing, but error decoding new subscription form request: %v\n", err)
//return ErrBadFormData
}
}
c, err := app.db.GetCollection(ss.CollAlias)
if err != nil {
log.Error("getCollection: %s", err)
return err
}
c.hostName = app.cfg.App.Host
from := c.CanonicalURL()
isAuthorBanned, err := app.db.IsUserSilenced(c.OwnerID)
if isAuthorBanned {
log.Info("Author is silenced, so subscription is blocked.")
return impart.HTTPError{http.StatusFound, from}
}
if ss.Web {
if u != nil && u.ID == c.OwnerID {
from = "/" + c.Alias + "/"
}
from += ss.Slug
}
if r.FormValue(spam.HoneypotFieldName()) != "" || r.FormValue("fake_password") != "" {
log.Info("Honeypot field was filled out! Not subscribing.")
return impart.HTTPError{http.StatusFound, from}
}
if ss.Email == "" && ss.UserID < 1 {
log.Info("No subscriber data. Not subscribing.")
return impart.HTTPError{http.StatusFound, from}
}
confirmed := app.db.IsSubscriberConfirmed(ss.Email)
es, err := app.db.AddEmailSubscription(c.ID, ss.UserID, ss.Email, confirmed)
if err != nil {
log.Error("addEmailSubscription: %s", err)
return err
}
// Send confirmation email if needed
if !confirmed {
err = sendSubConfirmEmail(app, c, ss.Email, es.ID, es.Token)
if err != nil {
log.Error("Failed to send subscription confirmation email: %s", err)
return err
}
}
if ss.Web {
session, err := app.sessionStore.Get(r, userEmailCookieName)
if err != nil {
// The cookie should still save, even if there's an error.
// Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144
log.Error("Getting user email cookie: %v; ignoring", err)
}
if confirmed {
addSessionFlash(app, w, r, "<strong>Subscribed</strong>. You'll now receive future blog posts via email.", nil)
} else {
addSessionFlash(app, w, r, "Please check your email and <strong>click the confirmation link</strong> to subscribe.", nil)
}
session.Values[userEmailCookieVal] = ss.Email
err = session.Save(r, w)
if err != nil {
log.Error("save email cookie: %s", err)
return err
}
return impart.HTTPError{http.StatusFound, from}
}
return impart.WriteSuccess(w, "", http.StatusAccepted)
}
func handleDeleteEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
alias := collectionAliasFromReq(r)
vars := mux.Vars(r)
subID := vars["subscriber"]
email := r.FormValue("email")
token := r.FormValue("t")
slug := r.FormValue("slug")
isWeb := r.Method == "GET"
// Display collection if this is a collection
var c *Collection
var err error
if app.cfg.App.SingleUser {
c, err = app.db.GetCollectionByID(1)
} else {
c, err = app.db.GetCollection(alias)
}
if err != nil {
log.Error("Get collection: %s", err)
return err
}
from := c.CanonicalURL()
if subID != "" {
// User unsubscribing via email, so assume action is taken by either current
// user or not current user, and only use the request's information to
// satisfy this unsubscribe, i.e. subscriberID and token.
err = app.db.DeleteEmailSubscriber(subID, token)
} else {
// User unsubscribing through the web app, so assume action is taken by
// currently-auth'd user.
var userID int64
u := getUserSession(app, r)
if u != nil {
// User is logged in
userID = u.ID
if userID == c.OwnerID {
from = "/" + c.Alias + "/"
}
}
if email == "" && userID <= 0 {
// Get email address from saved cookie
session, err := app.sessionStore.Get(r, userEmailCookieName)
if err != nil {
log.Error("Unable to get email cookie: %s", err)
} else {
email = session.Values[userEmailCookieVal].(string)
}
}
if email == "" && userID <= 0 {
err = fmt.Errorf("No subscriber given.")
log.Error("Not deleting subscription: %s", err)
return err
}
err = app.db.DeleteEmailSubscriberByUser(email, userID, c.ID)
}
if err != nil {
log.Error("Unable to delete subscriber: %v", err)
return err
}
if isWeb {
from += slug
addSessionFlash(app, w, r, "<strong>Unsubscribed</strong>. You will no longer receive these blog posts via email.", nil)
return impart.HTTPError{http.StatusFound, from}
}
return impart.WriteSuccess(w, "", http.StatusAccepted)
}
func handleConfirmEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
alias := collectionAliasFromReq(r)
subID := mux.Vars(r)["subscriber"]
token := r.FormValue("t")
var c *Collection
var err error
if app.cfg.App.SingleUser {
c, err = app.db.GetCollectionByID(1)
} else {
c, err = app.db.GetCollection(alias)
}
if err != nil {
log.Error("Get collection: %s", err)
return err
}
from := c.CanonicalURL()
err = app.db.UpdateSubscriberConfirmed(subID, token)
if err != nil {
addSessionFlash(app, w, r, err.Error(), nil)
return impart.HTTPError{http.StatusFound, from}
}
addSessionFlash(app, w, r, "<strong>Confirmed</strong>! Thanks. Now you'll receive future blog posts via email.", nil)
return impart.HTTPError{http.StatusFound, from}
}
func emailPost(app *App, p *PublicPost, collID int64) error {
p.augmentContent()
// Do some shortcode replacement.
// Since the user is receiving this email, we can assume they're subscribed via email.
p.Content = strings.Replace(p.Content, "<!--emailsub-->", `<p id="emailsub">You're subscribed to email updates.</p>`, -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%`
gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate)
m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg)
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(`<h2 id="title">` + p.FormattedDisplayTitle() + `</h2>`)
}
m.AddTag("New post")
fontFam := "Lora, Palatino, Baskerville, serif"
if p.IsSans() {
fontFam = `"Open Sans", Tahoma, Arial, sans-serif`
} else if p.IsMonospace() {
fontFam = `Hack, consolas, Menlo-Regular, Menlo, Monaco, monospace, monospace`
}
// TODO: move this to a templated file and LESS-generated stylesheet
fullHTML := `<html>
<head>
<style>
body {
font-size: 120%;
font-family: ` + fontFam + `;
margin: 1em 2em;
}
#article {
line-height: 1.5;
margin: 1.5em 0;
white-space: pre-wrap;
word-wrap: break-word;
}
h1, h2, h3, h4, h5, h6, p, code {
display: inline
}
img, iframe, video {
max-width: 100%
}
#title {
margin-bottom: 1em;
display: block;
}
.intro {
font-style: italic;
font-size: 0.95em;
}
div#footer {
text-align: center;
max-width: 35em;
margin: 2em auto;
}
div#footer p {
display: block;
font-size: 0.86em;
color: #666;
}
hr {
border: 1px solid #ccc;
margin: 2em 1em;
}
p#emailsub {
text-align: center;
display: inline-block !important;
width: 100%;
font-style: italic;
}
</style>
</head>
<body>
<div id="article">` + title + `<p class="intro">From <a href="` + p.CanonicalURL(app.cfg.App.Host) + `">` + p.DisplayCanonicalURL() + `</a></p>
` + string(p.HTMLContent) + `</div>
<hr />
<div id="footer">
<p>Originally published on <a href="` + p.Collection.CanonicalURL() + `">` + p.Collection.DisplayTitle() + `</a>, a blog you subscribe to.</p>
<p>Sent to %recipient.to%. <a href="` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%">Unsubscribe</a>.</p>
</div>
</body>
</html>`
// inline CSS
html, err := inliner.Inline(fullHTML)
if err != nil {
log.Error("Unable to inline email HTML: %v", err)
return err
}
m.SetHtml(html)
log.Info("[email] Adding %d recipient(s)", len(subs))
for _, s := range subs {
e := s.FinalEmail(app.keys)
log.Info("[email] Adding %s", e)
err = m.AddRecipientAndVariables(e, map[string]interface{}{
"id": s.ID,
"to": e,
"token": s.Token,
})
if err != nil {
log.Error("Unable to add receipient %s: %s", e, err)
}
}
res, _, err := gun.Send(m)
log.Info("[email] Send result: %s", res)
if err != nil {
log.Error("Unable to send post email: %v", err)
return err
}
return nil
}
func sendSubConfirmEmail(app *App, c *Collection, email, subID, token string) error {
if email == "" {
return fmt.Errorf("You must supply an email to verify.")
}
// Send email
gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate)
plainMsg := "Confirm your subscription to " + c.DisplayTitle() + ` (` + c.CanonicalURL() + `) to start receiving future posts. Simply click the following link (or copy and paste it into your browser):
` + c.CanonicalURL() + "email/confirm/" + subID + "?t=" + token + `
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.`
m := mailgun.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Email.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email))
m.AddTag("Email Verification")
m.SetHtml(`<html>
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
<div style="font-size: 1.2em;">
<p>Confirm your subscription to <a href="` + c.CanonicalURL() + `">` + c.DisplayTitle() + `</a> to start receiving future posts:</p>
<p><a href="` + c.CanonicalURL() + `email/confirm/` + subID + `?t=` + token + `">Subscribe to ` + c.DisplayTitle() + `</a></p>
<p>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.</p>
</div>
</body>
</html>`)
gun.Send(m)
return nil
}

59
go.mod
View File

@ -1,26 +1,41 @@
module github.com/writefreely/writefreely
require (
github.com/PuerkitoBio/goquery v1.8.1 // indirect
github.com/aymerick/douceur v0.2.0
github.com/clbanning/mxj v1.8.4 // indirect
github.com/dustin/go-humanize v1.0.1
github.com/fatih/color v1.15.0
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect
github.com/fatih/color v1.16.0
github.com/go-ini/ini v1.67.0
github.com/go-sql-driver/mysql v1.7.1
github.com/gorilla/csrf v1.7.1
github.com/gorilla/feeds v1.1.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.2.0
github.com/gorilla/sessions v1.2.1
github.com/go-test/deep v1.0.1 // indirect
github.com/gobuffalo/envy v1.9.0 // indirect
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
github.com/gorilla/csrf v1.7.2
github.com/gorilla/feeds v1.1.2
github.com/gorilla/mux v1.8.1
github.com/gorilla/schema v1.2.1
github.com/gorilla/sessions v1.2.2
github.com/guregu/null v4.0.0+incompatible
github.com/hashicorp/go-multierror v1.1.1
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
github.com/mailgun/mailgun-go v2.0.0+incompatible
github.com/manifoldco/promptui v0.9.0
github.com/mattn/go-sqlite3 v1.14.17
github.com/microcosm-cc/bluemonday v1.0.25
github.com/mattn/go-sqlite3 v1.14.21
github.com/microcosm-cc/bluemonday v1.0.26
github.com/mitchellh/go-wordwrap v1.0.1
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
github.com/stretchr/testify v1.8.4
github.com/urfave/cli/v2 v2.25.7
github.com/onsi/ginkgo v1.16.4 // indirect
github.com/onsi/gomega v1.13.0 // indirect
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
github.com/stretchr/testify v1.9.0
github.com/urfave/cli/v2 v2.27.1
github.com/writeas/activity v0.1.2
github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835
github.com/writeas/go-strip-markdown/v2 v2.1.1
@ -31,49 +46,45 @@ require (
github.com/writeas/monday v1.3.0
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320
github.com/writeas/slug v1.2.0
github.com/writeas/web-core v1.5.0
github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b
github.com/writefreely/go-nodeinfo v1.2.0
golang.org/x/crypto v0.13.0
golang.org/x/net v0.15.0
golang.org/x/crypto v0.21.0
golang.org/x/net v0.22.0
)
require (
code.as/core/socks v1.0.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/beevik/etree v1.1.0 // indirect
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe // indirect
github.com/go-test/deep v1.0.1 // indirect
github.com/gofrs/uuid v3.3.0+incompatible // indirect
github.com/gologme/log v1.2.0 // indirect
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/joho/godotenv v1.3.0 // indirect
github.com/jtolds/gls v4.2.1+incompatible // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sasha-s/go-deadlock v0.3.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
github.com/writeas/go-writeas/v2 v2.0.2 // indirect
github.com/writeas/openssl-go v1.0.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.62.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

182
go.sum
View File

@ -1,5 +1,10 @@
code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs=
code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY=
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
@ -24,10 +29,19 @@ github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c h1:8ISkoahWXwZR41ois5lSJBSVw4D0OV19Ht/JSTzvSv0=
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A=
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 h1:7HZCaLC5+BZpmbhCOZJ293Lz68O7PYrF2EzeiFMwCLk=
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe h1:U71giCx5NjRn4Lb71UuprPHqhjxGv3Jqonb9fgcaJH8=
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
@ -35,29 +49,46 @@ github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/gobuffalo/envy v1.9.0 h1:eZR0DuEgVLfeIb1zIKt3bT4YovIMf9O9LXQeCZLXpqE=
github.com/gobuffalo/envy v1.9.0/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w=
github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84=
github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c=
github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/csrf v1.7.1 h1:Ir3o2c1/Uzj6FBxMlAUB6SivgVMy1ONXwYgXn+/aHPE=
github.com/gorilla/csrf v1.7.1/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA=
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/gorilla/feeds v1.1.2 h1:pxzZ5PD3RJdhFH2FsJJ4x6PqMqbgFk1+Vez4XWBW8Iw=
github.com/gorilla/feeds v1.1.2/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/schema v1.2.1 h1:tjDxcmdb+siIqkTNoV+qRH2mjYdr2hHe5MKXbp61ziM=
github.com/gorilla/schema v1.2.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/guregu/null v4.0.0+incompatible h1:4zw0ckM7ECd6FNNddc3Fu4aty9nTlpkkzH7dPn4/4Gw=
github.com/guregu/null v4.0.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
@ -65,33 +96,51 @@ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM=
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec h1:ZXWuspqypleMuJy4bzYEqlMhJnGAYpLrWe5p7W3CdvI=
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g=
github.com/mailgun/mailgun-go v2.0.0+incompatible h1:0FoRHWwMUctnd8KIR3vtZbqdfjpIMxOZgcSa51s8F8o=
github.com/mailgun/mailgun-go v2.0.0+incompatible/go.mod h1:NWTyU+O4aczg/nsGhQnvHL6v2n5Gy6Sv5tNDVvC6FbU=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.21 h1:IXocQLOykluc3xPE0Lvy8FtggMz1G+U3mEjg+0zGizc=
github.com/mattn/go-sqlite3 v1.14.21/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4=
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.13.0 h1:7lLHu94wT9Ij0o6EWWclhu0aOh32VxhkwEJvzuWPeak=
github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY=
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -100,6 +149,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0=
@ -111,15 +163,15 @@ github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY=
github.com/writeas/activity v0.1.2/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0=
github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481 h1:BiSivIxLQFcKoUorpNN3rNwwFG5bITPnqUSyIccfdh0=
github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o=
github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835 h1:bm/7gYo6y3GxtTa1qyUFyCk29CTnBAKt7z4D2MASYrw=
github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o=
github.com/writeas/go-strip-markdown/v2 v2.1.1 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28=
@ -145,69 +197,123 @@ github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 h1:PozPZ29CQ/xt
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g=
github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ=
github.com/writeas/web-core v1.5.0 h1:jKJObEgzYoNWUsIJmIDLAjN+vADkSsfcj7Ir9YyFcPg=
github.com/writeas/web-core v1.5.0/go.mod h1:jb7dEvnyvPbKA3pTCzD1Dch5ou/n5YkkydZYhn6/NVY=
github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431 h1:ruqL2u87k504PXkR/fC4DcfZyyHmCindlpjOQKmyOsY=
github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431/go.mod h1:7+idL4Y4woF7MnUfNX2mvkaQ8nLIJXths2y5iYPtA3k=
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b h1:h3NzB8OZ50NNi5k9yrFeyFszt3LyqyVK4+xUHFYY8B0=
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b/go.mod h1:T2UVVzt+R5KSSZe2xRSytnwc2M9AoDegi7foeIsik+M=
github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss=
github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -262,7 +262,7 @@ func apiAuth(app *App, r *http.Request) (*User, error) {
return u, nil
}
// optionaAPIAuth is used for endpoints that accept authenticated requests via
// optionalAPIAuth is used for endpoints that accept authenticated requests via
// Authorization header or cookie, unlike apiAuth. It returns a different err
// in the case where no Authorization header is present.
func optionalAPIAuth(app *App, r *http.Request) (*User, error) {
@ -818,7 +818,7 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
return
} else if err.Status == http.StatusNotFound {
w.WriteHeader(err.Status)
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
if IsActivityPubRequest(r) {
// This is a fediverse request; simply return the header
return
}

72
jobs.go Normal file
View File

@ -0,0 +1,72 @@
package writefreely
import (
"github.com/writeas/web-core/log"
"time"
)
type PostJob struct {
ID int64
PostID string
Action string
Delay int64
}
func addJob(app *App, p *PublicPost, action string, delay int64) error {
j := &PostJob{
PostID: p.ID,
Action: action,
Delay: delay,
}
return app.db.InsertJob(j)
}
func startPublishJobsQueue(app *App) {
t := time.NewTicker(62 * time.Second)
for {
log.Info("[jobs] Done.")
<-t.C
log.Info("[jobs] Fetching email publish jobs...")
jobs, err := app.db.GetJobsToRun("email")
if err != nil {
log.Error("[jobs] %s - Skipping.", err)
continue
}
log.Info("[jobs] Running %d email publish jobs...", len(jobs))
err = runJobs(app, jobs, true)
if err != nil {
log.Error("[jobs] Failed: %s", err)
}
}
}
func runJobs(app *App, jobs []*PostJob, reqColl bool) error {
for _, j := range jobs {
p, err := app.db.GetPost(j.PostID, 0)
if err != nil {
log.Info("[job #%d] Unable to get post: %s", j.ID, err)
continue
}
if !p.CollectionID.Valid && reqColl {
log.Info("[job #%d] Post %s not part of a collection", j.ID, p.ID)
app.db.DeleteJob(j.ID)
continue
}
coll, err := app.db.GetCollectionByID(p.CollectionID.Int64)
if err != nil {
log.Info("[job #%d] Unable to get collection: %s", j.ID, err)
continue
}
coll.hostName = app.cfg.App.Host
coll.ForPublic()
p.Collection = &CollectionObj{Collection: *coll}
err = emailPost(app, p, p.Collection.ID)
if err != nil {
log.Error("[job #%d] Failed to email post %s", j.ID, p.ID)
continue
}
log.Info("[job #%d] Success for post %s.", j.ID, p.ID)
app.db.DeleteJob(j.ID)
}
return nil
}

View File

@ -51,7 +51,7 @@ func initKeyPaths(app *App) {
func generateKey(path string) error {
// Check if key file exists
if _, err := os.Stat(path); err == nil {
log.Info("%s already exists. rm the file if you understand the consquences.", path)
log.Info("%s already exists. rm the file if you understand the consequences.", path)
return nil
} else if !os.IsNotExist(err) {
log.Error("%s", err)

View File

@ -60,6 +60,35 @@ nav#admin {
background: #ccc;
}
}
&.sub {
margin: 1em 0 2em;
a:not(.toggle) {
border: 0;
border-bottom: 2px transparent solid;
.rounded(0);
padding: 0.5em;
margin-left: 0.5em;
margin-right: 0.5em;
&:hover {
color: @primary;
background: transparent;
}
&.selected {
color: @primary;
background: transparent;
border-bottom-color: @primary;
}
&+a {
margin-left: 1em;
}
}
a.toggle {
margin-top: -0.5em;
float: right;
}
}
}
.admin-actions {

View File

@ -210,6 +210,10 @@ body {
pre {
line-height: 1.5;
}
.flash {
text-align: center;
margin-bottom: 4em;
}
}
&#subpage {
#wrapper {
@ -827,6 +831,9 @@ input {
margin: 0 auto 3em;
font-size: 1.2em;
&.toosmall {
max-width: 25em;
}
&.tight {
max-width: 30em;
}
@ -1597,6 +1604,18 @@ pre.code-block {
overflow-x: auto;
}
#emailsub {
text-align: center;
}
p#emailsub {
display: inline-block !important;
width: 100%;
font-style: italic;
}
#subscribe-btn {
margin-left: 0.5em;
}
#org-nav {
font-family: @sansFont;
font-size: 1.1em;

View File

@ -3,7 +3,6 @@
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: optional;
src: url('/fonts/open-sans-v13-latin-regular.eot'); /* IE9 Compat Modes */
src: local('Open Sans'), local('OpenSans'),
url('/fonts/open-sans-v13-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
@ -17,7 +16,6 @@
font-family: 'Open Sans';
font-style: normal;
font-weight: 700;
font-display: optional;
src: url('/fonts/open-sans-v13-latin-700.eot'); /* IE9 Compat Modes */
src: local('Open Sans Bold'), local('OpenSans-Bold'),
url('/fonts/open-sans-v13-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
@ -31,7 +29,6 @@
font-family: 'Lora';
font-style: normal;
font-weight: 400;
font-display: optional;
src: url('/fonts/Lora-Regular.eot'); /* IE9 Compat Modes */
src: local('Lora'), local('Lora-Regular'),
url('/fonts/Lora-Regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
@ -44,7 +41,6 @@
font-family: 'Lora';
font-style: normal;
font-weight: 700;
font-display: optional;
src: url('/fonts/Lora-Bold.eot'); /* IE9 Compat Modes */
src: local('Lora Bold'), local('Lora-Bold'),
url('/fonts/Lora-Bold.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
@ -56,7 +52,6 @@
font-family: 'Lora';
font-style: italic;
font-weight: 400;
font-display: optional;
src: url('/fonts/Lora-Italic.eot'); /* IE9 Compat Modes */
src: local('Lora Italic'), local('Lora-Italic'),
url('/fonts/Lora-Italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */

View File

@ -36,6 +36,13 @@ func (db *datastore) typeSmallInt() string {
return "SMALLINT"
}
func (db *datastore) typeTinyInt() string {
if db.driverName == driverSQLite {
return "INTEGER"
}
return "TINYINT"
}
func (db *datastore) typeText() string {
return "TEXT"
}
@ -54,6 +61,13 @@ func (db *datastore) typeVarChar(l int) string {
return fmt.Sprintf("VARCHAR(%d)", l)
}
func (db *datastore) typeVarBinary(l int) string {
if db.driverName == driverSQLite {
return "BLOB"
}
return fmt.Sprintf("VARBINARY(%d)", l)
}
func (db *datastore) typeBool() string {
if db.driverName == driverSQLite {
return "INTEGER"
@ -65,6 +79,15 @@ func (db *datastore) typeDateTime() string {
return "DATETIME"
}
func (db *datastore) typeIntPrimaryKey() string {
if db.driverName == driverSQLite {
// From docs: "In SQLite, a column with type INTEGER PRIMARY KEY is an alias for the ROWID (except in WITHOUT
// ROWID tables) which is always a 64-bit signed integer."
return "INTEGER PRIMARY KEY"
}
return "INT AUTO_INCREMENT PRIMARY KEY"
}
func (db *datastore) collateMultiByte() string {
if db.driverName == driverSQLite {
return ""

View File

@ -65,9 +65,12 @@ var migrations = []Migration{
New("support oauth attach", oauthAttach), // V6 -> V7
New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0)
New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9
New("support post signatures", supportPostSignatures), // V9 -> V10
New("support post signatures", supportPostSignatures), // V9 -> V10 (v0.13.0)
New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11
New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12
New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 (v0.14.0)
New("support newsletters", supportLetters), // V12 -> V13
New("support password resetting", supportPassReset), // V13 -> V14
New("speed up blog post retrieval", addPostRetrievalIndex), // V14 -> V15
}
// CurrentVer returns the current migration version the application is on

58
migrations/v13.go Normal file
View File

@ -0,0 +1,58 @@
/*
* Copyright © 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 migrations
func supportLetters(db *datastore) error {
t, err := db.Begin()
if err != nil {
t.Rollback()
return err
}
_, err = t.Exec(`CREATE TABLE publishjobs (
id ` + db.typeIntPrimaryKey() + `,
post_id ` + db.typeVarChar(16) + ` not null,
action ` + db.typeVarChar(16) + ` not null,
delay ` + db.typeTinyInt() + ` not null
)`)
if err != nil {
t.Rollback()
return err
}
_, err = t.Exec(`CREATE TABLE emailsubscribers (
id ` + db.typeChar(8) + ` not null,
collection_id ` + db.typeInt() + ` not null,
user_id ` + db.typeInt() + ` null,
email ` + db.typeVarChar(255) + ` null,
subscribed ` + db.typeDateTime() + ` not null,
token ` + db.typeChar(16) + ` not null,
confirmed ` + db.typeBool() + ` default 0 not null,
allow_export ` + db.typeBool() + ` default 0 not null,
constraint eu_coll_email
unique (collection_id, email),
constraint eu_coll_user
unique (collection_id, user_id),
PRIMARY KEY (id)
)`)
if err != nil {
t.Rollback()
return err
}
err = t.Commit()
if err != nil {
t.Rollback()
return err
}
return nil
}

37
migrations/v14.go Normal file
View File

@ -0,0 +1,37 @@
/*
* Copyright © 2023 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 migrations
func supportPassReset(db *datastore) error {
t, err := db.Begin()
if err != nil {
t.Rollback()
return err
}
_, err = t.Exec(`CREATE TABLE password_resets (
user_id ` + db.typeInt() + ` not null,
token ` + db.typeChar(32) + ` not null primary key,
used ` + db.typeBool() + ` default 0 not null,
created ` + db.typeDateTime() + ` not null
)`)
if err != nil {
t.Rollback()
return err
}
err = t.Commit()
if err != nil {
t.Rollback()
return err
}
return nil
}

33
migrations/v15.go Normal file
View File

@ -0,0 +1,33 @@
/*
* Copyright © 2023 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 migrations
func addPostRetrievalIndex(db *datastore) error {
t, err := db.Begin()
if err != nil {
t.Rollback()
return err
}
_, err = t.Exec("CREATE INDEX posts_get_collection_index ON posts (`collection_id`, `pinned_position`, `created`)")
if err != nil {
t.Rollback()
return err
}
err = t.Commit()
if err != nil {
t.Rollback()
return err
}
return nil
}

View File

@ -109,7 +109,7 @@ func defaultPrivacyPolicy(cfg *config.Config) string {
It retains as little data about you as possible, not even requiring an email address to sign up. However, if you _do_ give us your email address, it is stored encrypted in our database. We salt and hash your account's password.
We store log files, or data about what happens on our servers. We also use cookies to keep you logged in to your account.
We store log files, or data about what happens on our servers. We also use cookies to keep you logged into your account.
Beyond this, it's important that you trust whoever runs **` + cfg.App.SiteName + `**. Software can only do so much to protect you -- your level of privacy protections will ultimately fall on the humans that run this particular service.`
}

View File

@ -1,13 +1,19 @@
{{define "head"}}<title>Log in &mdash; {{.SiteName}}</title>
<meta name="description" content="Log in to {{.SiteName}}.">
<meta itemprop="description" content="Log in to {{.SiteName}}.">
<meta name="description" content="Log into {{.SiteName}}.">
<meta itemprop="description" content="Log into {{.SiteName}}.">
<style>
input{margin-bottom:0.5em;}
p.forgot {
font-size: 0.8em;
margin: 0 auto 1.5rem;
text-align: left;
max-width: 16rem;
}
</style>
{{end}}
{{define "content"}}
<div class="tight content-container">
<h1>Log in to {{.SiteName}}</h1>
<h1>Log into {{.SiteName}}</h1>
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
@ -19,6 +25,7 @@ input{margin-bottom:0.5em;}
<form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
<input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br />
<input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br />
{{if .EmailEnabled}}<p class="forgot"><a href="/reset">Forgot password?</a></p>{{end}}
{{if .To}}<input type="hidden" name="to" value="{{.To}}" />{{end}}
<input type="submit" id="btn-login" value="Login" />
</form>

58
pages/reset.tmpl Normal file
View File

@ -0,0 +1,58 @@
{{define "head"}}<title>Reset password &mdash; {{.SiteName}}</title>
<style>
input {
margin-bottom: 0.5em;
width: 100%;
box-sizing: border-box;
}
label {
display: block;
}
</style>
{{end}}
{{define "content"}}
<div class="toosmall content-container clean">
<h1>Reset your password</h1>
{{ if .DisablePasswordAuth }}
<div class="alert info">
<p><strong>Password login is disabled on this server</strong>, so it's not possible to reset your password.</p>
</div>
{{ else if not .EmailEnabled }}
<div class="alert info">
<p><strong>Email is not configured on this server!</strong> Please <a href="/contact">contact your admin</a> to reset your password.</p>
</div>
{{ else }}
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
{{if .IsResetting}}
<form method="post" action="/reset" onsubmit="disableSubmit()">
<label>
<p>New Password</p>
<input type="password" name="new-pass" autocomplete="new-password" placeholder="New password" tabindex="1" />
</label>
<input type="hidden" name="t" value="{{.Token}}" />
<input type="submit" id="btn-login" value="Reset Password" />
{{ .CSRFField }}
</form>
{{else if not .IsSent}}
<form action="/reset" method="post" onsubmit="disableSubmit()">
<label>
<p>Username</p>
<input type="text" name="alias" placeholder="Username" autofocus />
</label>
{{ .CSRFField }}
<input type="submit" id="btn-login" value="Reset Password" />
</form>
{{end}}
<script type="text/javascript">
var $btn = document.getElementById("btn-login");
function disableSubmit() {
$btn.disabled = true;
}
</script>
{{ end }}
{{end}}

View File

@ -80,3 +80,12 @@ func TruncToWord(s string, l int) (string, bool) {
}
return s, truncated
}
// Truncate truncates the given text to the provided limit, returning the original string if it's shorter than the limit.
func Truncate(s string, l int) string {
c := []rune(s)
if len(c) > l {
s = string(c[:l])
}
return s
}

View File

@ -14,6 +14,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"github.com/writefreely/writefreely/spam"
"html/template"
"net/http"
"net/url"
@ -340,6 +341,7 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
}
var ownerID sql.NullInt64
var collectionID sql.NullInt64
var title string
var content string
var font string
@ -355,7 +357,7 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/%s%s", fixedID, ext)}
}
err := app.db.QueryRow("SELECT owner_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?", friendlyID).Scan(&ownerID, &title, &content, &font, &views, &language, &rtl)
err := app.db.QueryRow("SELECT owner_id, collection_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?", friendlyID).Scan(&ownerID, &collectionID, &title, &content, &font, &views, &language, &rtl)
switch {
case err == sql.ErrNoRows:
found = false
@ -425,6 +427,16 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
}
}
var protectDraft bool
if found && collectionID.Valid {
collection, err := app.db.GetCollectionByID(collectionID.Int64)
if err != nil {
log.Error("view post: %v", err)
}
protectDraft = collection.IsPrivate() || collection.IsProtected()
}
// Check if post has been unpublished
if title == "" && content == "" {
gone = true
@ -489,6 +501,10 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
if !page.IsOwner && silenced {
return ErrPostNotFound
}
if !page.IsOwner && protectDraft {
return ErrPostNotFound
}
page.Silenced = silenced
err = templates["post"].ExecuteTemplate(w, "post", page)
if err != nil {
@ -652,8 +668,17 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
// 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)
if newPost.Collection != nil {
if !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
go federatePost(app, newPost, newPost.Collection.ID, false)
}
if app.cfg.Email.Enabled() && newPost.Collection.EmailSubsEnabled() {
go app.db.InsertJob(&PostJob{
PostID: newPost.ID,
Action: "email",
Delay: emailSendDelay,
})
}
}
return response
@ -953,16 +978,23 @@ func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
return err
}
if !app.cfg.App.Private && app.cfg.App.Federation {
for _, pRes := range *res {
if pRes.Code != http.StatusOK {
continue
}
for _, pRes := range *res {
if pRes.Code != http.StatusOK {
continue
}
if !app.cfg.App.Private && app.cfg.App.Federation {
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)
}
}
if app.cfg.Email.Enabled() && pRes.Post.Collection.EmailSubsEnabled() {
go app.db.InsertJob(&PostJob{
PostID: pRes.Post.ID,
Action: "email",
Delay: emailSendDelay,
})
}
}
return impart.WriteSuccess(w, res, http.StatusOK)
}
@ -1068,7 +1100,7 @@ func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
ppr := PinPostResult{ID: p.ID}
if err != nil {
ppr.Code = http.StatusInternalServerError
// TODO: set error messsage
// TODO: set error message
} else {
ppr.Code = http.StatusOK
}
@ -1164,6 +1196,15 @@ func (p *PublicPost) CanonicalURL(hostName string) string {
return p.Collection.CanonicalURL() + p.Slug.String
}
func (pp *PublicPost) DisplayCanonicalURL() string {
us := pp.CanonicalURL(pp.Collection.hostName)
u, err := url.Parse(us)
if err != nil {
return us
}
return u.Hostname() + u.Path
}
func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
cfg := app.cfg
var o *activitystreams.Object
@ -1520,7 +1561,7 @@ Are you sure it was ever here?`,
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") {
} else if IsActivityPubRequest(r) {
if !postFound {
return ErrCollectionPageNotFound
}
@ -1532,6 +1573,15 @@ Are you sure it was ever here?`,
} else {
p.extractData()
p.Content = strings.Replace(p.Content, "<!--more-->", "", 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, "<!--emailsub-->", `<p id="emailsub">You're subscribed to email updates. <a href="/api/collections/`+c.Alias+`/email/unsubscribe?slug=`+p.Slug.String+`">Unsubscribe</a>.</p>`, -1)
} else {
p.Content = strings.Replace(p.Content, "<!--emailsub-->", `<form method="post" id="emailsub" action="/api/collections/`+c.Alias+`/email/subscribe"><input type="hidden" name="slug" value="`+p.Slug.String+`" /><input type="hidden" name="web" value="1" /><div style="position: absolute; left: -5000px;" aria-hidden="true"><input type="email" name="`+spam.HoneypotFieldName()+`" tabindex="-1" value="" /><input type="password" name="fake_password" tabindex="-1" placeholder="password" autocomplete="new-password" /></div><input type="email" name="email" placeholder="me@example.com" /><input type="submit" id="subscribe-btn" value="Subscribe" /></form>`, -1)
}
}
p.Content = strings.Replace(p.Content, "&lt;!--emailsub-->", "<!--emailsub-->", 1)
// TODO: move this to function
p.formatContent(app.cfg, cr.isCollOwner, true)
tp := CollectionPostPage{
@ -1596,6 +1646,14 @@ func (p *Post) extractData() {
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)
}
@ -1611,7 +1669,7 @@ func (rp *RawPost) Updated8601() string {
return rp.Updated.Format("2006-01-02T15:04:05Z")
}
var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|image)$`)
var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|avif|avifs|webp|jxl|image)$`)
func (p *Post) extractImages() {
p.Images = extractImages(p.Content)

View File

@ -229,11 +229,9 @@ func showLocalTimeline(app *App, w http.ResponseWriter, r *http.Request, page in
TotalPages: ttlPages,
SelTopic: tag,
}
if app.cfg.App.Chorus {
u := getUserSession(app, r)
d.IsAdmin = u != nil && u.IsAdmin()
d.CanInvite = canUserInvite(app.cfg, d.IsAdmin)
}
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

View File

@ -82,7 +82,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
configureGenericOauth(handler, write, apper.App())
configureGiteaOauth(handler, write, apper.App())
// Set up dyamic page handlers
// Set up dynamic page handlers
// Handle auth
auth := write.PathPrefix("/api/auth/").Subrouter()
if apper.App().cfg.App.OpenRegistration {
@ -99,6 +99,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
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")
@ -147,6 +148,9 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
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/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")
@ -180,6 +184,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
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")
@ -223,6 +228,8 @@ func RouteCollections(handler *Handler, r *mux.Router) {
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))

View File

@ -2,7 +2,7 @@
###############################################################################
## writefreely update script ##
## ##
## WARNING: running this script will overwrite any modifed assets or ##
## WARNING: running this script will overwrite any modified assets or ##
## template files. If you have any custom changes to these files you ##
## should back them up FIRST. ##
## ##

View File

@ -21,6 +21,10 @@ import (
const (
day = 86400
sessionLength = 180 * day
userEmailCookieName = "ue"
userEmailCookieVal = "email"
cookieName = "wfu"
cookieUserVal = "u"

43
spam/email.go Normal file
View File

@ -0,0 +1,43 @@
/*
* Copyright © 2020-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 spam
import (
"github.com/writeas/web-core/id"
"strings"
)
var honeypotField string
func HoneypotFieldName() string {
if honeypotField == "" {
honeypotField = id.Generate62RandomString(39)
}
return honeypotField
}
// CleanEmail takes an email address and strips it down to a unique address that can be blocked.
func CleanEmail(email string) string {
emailParts := strings.Split(strings.ToLower(email), "@")
if len(emailParts) < 2 {
return ""
}
u := emailParts[0]
d := emailParts[1]
// Ignore anything after '+'
plusIdx := strings.IndexRune(u, '+')
if plusIdx > -1 {
u = u[:plusIdx]
}
// Strip dots in email address
u = strings.ReplaceAll(u, ".", "")
return u + "@" + d
}

25
spam/ip.go Normal file
View File

@ -0,0 +1,25 @@
/*
* Copyright © 2023 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 spam
import (
"net/http"
"strings"
)
func GetIP(r *http.Request) string {
h := r.Header.Get("X-Forwarded-For")
if h == "" {
return ""
}
ips := strings.Split(h, ",")
return strings.TrimSpace(ips[0])
}

View File

@ -1,6 +1,6 @@
# static/js
This directory is for Javascript.
This directory is for JavaScript.
## Updating libraries

View File

@ -14,7 +14,7 @@
<div id="overlay"></div>
<textarea id="writer" placeholder="Write..." class="{{.Post.Font}}" autofocus>{{if .Post.Title}}# {{.Post.Title}}
<textarea dir="auto" id="writer" placeholder="Write..." class="{{.Post.Font}}" autofocus>{{if .Post.Title}}# {{.Post.Title}}
{{end}}{{.Post.Content}}</textarea>
@ -28,7 +28,7 @@
</ul></nav>
<span id="wc" class="hidden if-room room-4">0 words</span>
</div>
<noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need Javascript enabled to post.</noscript>
<noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need JavaScript enabled to post.</noscript>
<div id="belt">
{{if .Editing}}<div class="tool hidden if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit/meta{{else}}/{{if .SingleUser}}d/{{end}}{{.Post.Id}}/meta{{end}}" title="Edit post metadata" id="edit-meta"><img class="ic-24dp" src="/img/ic_info_dark@2x.png" /></a></div>{{end}}
<div class="tool"><button title="Publish your writing" id="publish" style="font-weight: bold">Post</button></div>

View File

@ -28,6 +28,7 @@
<ul><li class="has-submenu"><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
{{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}}
<li><a href="/me/settings">Account settings</a></li>
<li><a href="/me/import">Import posts</a></li>
<li><a href="/me/export">Export</a></li>
{{if .CanInvite}}<li><a href="/me/invites">Invite people</a></li>{{end}}
<li class="separator"><hr /></li>

View File

@ -62,7 +62,7 @@
</ul></nav>
<span id="wc" class="hidden if-room room-4">0 words</span>
</div>
<noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need Javascript enabled to post.</noscript>
<noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need JavaScript enabled to post.</noscript>
<div id="belt">
{{if .Editing}}<div class="tool hidden if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit/meta{{else}}/{{if .SingleUser}}d/{{end}}{{.Post.Id}}/meta{{end}}" title="Edit post metadata" id="edit-meta"><img class="ic-24dp" src="/img/ic_info_dark@2x.png" /></a></div>{{end}}
<div class="tool hidden if-room room-2"><a href="#theme" title="Toggle theme" id="toggle-theme"><img class="ic-24dp" src="/img/ic_brightness_dark@2x.png" /></a></div>

View File

@ -54,6 +54,7 @@
{{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><a href="/me/c/{{.Alias}}/subscribers">Subscribers</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>
@ -103,6 +104,12 @@
</div>
{{end}}
{{if .Flash}}
<div class="alert success flash">
<p>{{.Flash}}</p>
</div>
{{end}}
{{template "posts" .}}
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
@ -115,6 +122,8 @@
{{end}}
</nav>{{end}}
{{if not .IsWelcome}}{{template "emailsubscribe" .}}{{end}}
{{if .Posts}}</section>{{else}}</div>{{end}}
{{if .ShowFooterBranding }}

View File

@ -8,6 +8,7 @@
<a href="/about">about</a>
{{if and .LocalTimeline .CanViewReader}}<a href="/read">reader</a>{{end}}
{{if .Username}}<a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a>{{end}}
<a href="/contact">contact</a>
<a href="/privacy">privacy</a>
<p style="font-size: 0.9em">powered by <a href="https://writefreely.org">writefreely</a></p>
{{else}}
@ -25,6 +26,7 @@
<ul>
<li><a href="/about">about</a></li>
{{if and (and (not .SingleUser) .LocalTimeline) .CanViewReader}}<a href="/read">reader</a>{{end}}
<li><a href="/contact">contact</a></li>
<li><a href="/privacy">privacy</a></li>
</ul>
</div>

View File

@ -1,4 +1,4 @@
<!-- Miscelaneous render related template parts we use multiple times -->
<!-- Miscellaneous render related template parts we use multiple times -->
{{define "collection-meta"}}
{{if .Monetization -}}
<meta name="monetization" content="{{.DisplayMonetization}}" />
@ -79,7 +79,7 @@
jss.push(lurl);
}
}
// Load files in order, higlight on last load
// Load files in order, highlight on last load
loadLanguages(jss, () => {highlight(lb)});
}
});
@ -105,3 +105,28 @@
<script type="text/javascript" id="MathJax-script" src="/js/mathjax/tex-svg-full.js" async>
</script>
{{end}}
{{define "emailsubscribe"}}
{{if .EmailSubsEnabled}}
<div id="emailsub">
{{if .IsSubscriber}}
<p>You're subscribed to email updates. <a href="/api/collections/{{.Alias}}/email/unsubscribe">Unsubscribe</a>.</p>
{{else}}
<form method="post" action="/api/collections/{{.Alias}}/email/subscribe">
<input type="hidden" name="web" value="1" />
<p>Enter your email to subscribe to updates.</p> <div style="position: absolute; left: -5000px;" aria-hidden="true"><input type="email" name="{{.Honeypot}}" tabindex="-1" value="" /><input type="password" name="fake_password" tabindex="-1" placeholder="password" autocomplete="new-password" /></div>
<input type="email" name="email" placeholder="me@example.com" />
<input type="submit" id="subscribe-btn" value="Subscribe" />
</form>
<script type="text/javascript">
var $form = document.getElementById('emailsub').getElementsByTagName('form')[0];
$form.onsubmit = function() {
var $sub = document.getElementById('subscribe-btn');
$sub.disabled = true;
$sub.value = 'Subscribing...';
}
</script>
{{end}}
</div>
{{end}}
{{end}}

View File

@ -14,7 +14,7 @@
<div id="overlay"></div>
<textarea id="writer" placeholder="Write..." class="{{.Post.Font}}" autofocus>{{if .Post.Title}}# {{.Post.Title}}
<textarea dir="auto" id="writer" placeholder="Write..." class="{{.Post.Font}}" autofocus>{{if .Post.Title}}# {{.Post.Title}}
{{end}}{{.Post.Content}}</textarea>
@ -57,7 +57,7 @@
</ul></nav>
<span id="wc" class="hidden if-room room-4">0 words</span>
</div>
<noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need Javascript enabled to post.</noscript>
<noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need JavaScript enabled to post.</noscript>
<div id="belt">
{{if .Editing}}<div class="tool hidden if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit/meta{{else}}/{{if .SingleUser}}d/{{end}}{{.Post.Id}}/meta{{end}}" title="Edit post metadata" id="edit-meta"><img class="ic-24dp" src="/img/ic_info_dark@2x.png" /></a></div>{{end}}
<div class="tool hidden if-room room-2"><a href="#theme" title="Toggle theme" id="toggle-theme"><img class="ic-24dp" src="/img/ic_brightness_dark@2x.png" /></a></div>

View File

@ -19,7 +19,7 @@
<meta name="description" content="{{.Description}}">
<meta itemprop="name" content="{{.SiteName}}">
<meta itemprop="description" content="{{.Description}}">
<meta name="twitter:card" content="summary">
<meta name="twitter:card" content="{{if gt (len .Images) 0}}summary_large_image{{else}}summary{{end}}">
<meta name="twitter:title" content="{{if .Title}}{{.Title}}{{else}}{{.GenTitle}}{{end}}">
<meta name="twitter:description" content="{{.Description}}">
{{if gt .Views 1}}<meta name="twitter:label1" value="Views">

View File

@ -4,7 +4,7 @@
<div class="snug content-container">
{{template "admin-header" .}}
<!-- TODO: if other use for flashes use patern like account_import.go -->
<!-- TODO: if other use for flashes use pattern like account_import.go -->
{{if .Flashes}}
<p class="alert success">
{{range .Flashes}}{{.}}{{end}}

View File

@ -45,7 +45,7 @@ input.copy-text {
{{if .NewPassword}}<div class="alert success">
<p>This user's password has been reset to:</p>
<p><input type="text" class="copy-text" value="{{.NewPassword}}" onfocus="if (this.select) this.select(); else this.setSelectionRange(0, this.value.length);" readonly /></p>
<p>They can use this new password to log in to their account. <strong>This will only be shown once</strong>, so be sure to copy it and send it to them now.</p>
<p>They can use this new password to log into their account. <strong>This will only be shown once</strong>, so be sure to copy it and send it to them now.</p>
{{if .ClearEmail}}<p>Their email address is: <a href="mailto:{{.ClearEmail}}">{{.ClearEmail}}</a></p>{{end}}
</div>
{{end}}

View File

@ -36,7 +36,7 @@ textarea.section.norm {
<form name="customize-form" action="/api/collections/{{.Alias}}" method="post" onsubmit="return disableSubmit()">
<div id="collection-options">
<div style="text-align:center">
<h1><input type="text" name="title" id="title" value="{{.DisplayTitle}}" placeholder="Title" /></h1>
<h1><input type="text" name="title" id="title" value="{{.DisplayTitle}}" placeholder="Title" maxlength="255" /></h1>
<p><input type="text" name="description" id="description" value="{{.Description}}" placeholder="Description" maxlength="160" /></p>
</div>
@ -90,6 +90,44 @@ textarea.section.norm {
</div>
</div>
<div class="option">
<h2 id="updates">Updates</h2>
<div class="section">
<p class="explain">Keep readers updated with your latest posts wherever they are.</p>
<ul style="list-style:none">
<li>
<label class="option-text"><input type="checkbox" checked="checked" disabled />
RSS feed
</label>
<p class="describe">Readers can subscribe to your blog's <a href="{{.CanonicalURL}}feed/" target="feed">RSS feed</a> with their favorite RSS reader.</p>
</li>
{{if .EmailCfg.Enabled}}
<li>
<label class="option-text" id="email-sub-label"><input type="checkbox" name="email_subs" id="email_subs" {{if .EmailSubsEnabled}}checked="checked"{{end}} />
Email subscriptions
</label>
<p class="describe">
Let readers subscribe to your blog via email, and optionally accept private replies.
</p>
<div id="custom-letter-reply" style="font-size: .8em; margin-top: -0.5em; margin-left: 1.8em; margin-bottom: 1em;" {{if not .EmailSubsEnabled}}style="display:none"{{end}}>
Allow replies to this address:
<input type="email" name="letter_reply" id="letter_reply" placeholder="me@example.com" value="{{.LetterReplyTo}}" {{if not .EmailSubsEnabled}}disabled{{end}} />
</div>
</li>
{{end}}
{{if .Federation}}
<li>
<label class="option-text" id="federate-label"><input type="checkbox" name="federate" id="federate" {{if .Federation}}checked="checked"{{end}} disabled />
Federation
</label>
<strong id="normal-handle-env" class="fedi-handle">@<span id="fedi-handle">{{.Alias}}</span>@<span id="fedi-domain">{{.FriendlyHost}}</span></strong>
<p class="describe">Allow others to follow your blog and interact with your posts in the fediverse. <a href="https://video.writeas.org/videos/watch/cc55e615-d204-417c-9575-7b57674cc6f3" target="video">See how it works</a>.</p>
</li>
{{end}}
</ul>
</div>
</div>
<div class="option">
<h2>Display Format</h2>
<div class="section">
@ -254,6 +292,13 @@ var $customDomain = document.getElementById('domain-alias');
var $customHandleEnv = document.getElementById('custom-handle-env');
var $normalHandleEnv = document.getElementById('normal-handle-env');
var $emailSubsCheck = document.getElementById('email_subs');
var $letterReply = document.getElementById('letter_reply');
H.getEl('email_subs').on('click', function() {
let show = $emailSubsCheck.checked
$letterReply.disabled = !show
})
if (matchMedia('(pointer:fine)').matches) {
// Only initialize Ace editor on devices with a mouse
var opt = {

View File

@ -11,6 +11,7 @@
{{if not .SingleUser}}<a href="/about">about</a>{{end}}
{{if and (not .SingleUser) .LocalTimeline}}<a href="/read">reader</a>{{end}}
<a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a>
{{if not .SingleUser}}<a href="/contact">contact</a>{{end}}
{{if not .SingleUser}}<a href="/privacy">privacy</a>{{end}}
{{if .WFModesty}}
<p style="font-size: 0.9em">powered by <a href="https://writefreely.org">writefreely</a></p>

View File

@ -18,6 +18,7 @@
<nav class="tabs">
<a href="/me/c/{{.Username}}" {{if and (hasPrefix .Path "/me/c/") (hasSuffix .Path .Username)}}class="selected"{{end}}>Customize</a>
<a href="/me/c/{{.Username}}/stats" {{if hasSuffix .Path "/stats"}}class="selected"{{end}}>Stats</a>
<a href="/me/c/{{.Username}}/subscribers" {{if hasSuffix .Path "/subscribers"}}class="selected"{{end}}>Subscribers</a>
<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>
</nav>
</nav>

View File

@ -9,6 +9,7 @@
{{if .CanPost}}<a href="{{if .SingleUser}}/me/new{{else}}/#{{.Alias}}{{end}}" class="btn gentlecta">New Post</a>{{end}}
<a href="/me/c/{{.Alias}}" {{if and (hasPrefix .Path "/me/c/") (hasSuffix .Path .Alias)}}class="selected"{{end}}>Customize</a>
<a href="/me/c/{{.Alias}}/stats" {{if hasSuffix .Path "/stats"}}class="selected"{{end}}>Stats</a>
<a href="/me/c/{{.Alias}}/subscribers" {{if hasSuffix .Path "/subscribers"}}class="selected"{{end}}>Subscribers</a>
<a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">View Blog &rarr;</a>
</nav>
</header>

View File

@ -55,7 +55,7 @@ h3 { font-weight: normal; }
<div class="option">
<h3>Passphrase</h3>
<div class="section">
{{if and (not .HasPass) (not .IsLogOut)}}<div class="alert info"><p>Add a passphrase to easily log in to your account.</p></div>{{end}}
{{if and (not .HasPass) (not .IsLogOut)}}<div class="alert info"><p>Add a passphrase to easily log into your account.</p></div>{{end}}
{{if .HasPass}}<p>Current passphrase</p>
<input type="password" name="current-pass" placeholder="Current passphrase" tabindex="1" /> <input class="show" type="checkbox" id="show-cur-pass" /><label for="show-cur-pass"> Show</label>
<p>New passphrase</p>

View File

@ -30,15 +30,17 @@ td.none {
{{end}}
<p>Stats for all time.</p>
{{if .Federation}}
<h3>Fediverse stats</h3>
{{if or .Federation .EmailEnabled}}
<h3>Subscribers</h3>
<table id="fediverse" class="classy export">
<tr>
<th>Followers</th>
{{if .Federation}}<th>Fediverse Followers</th>{{end}}
{{if .EmailEnabled}}<th>Email Subscribers</th>{{end}}
</tr>
<tr>
<td>{{.APFollowers}}</td>
{{if .Federation}}<td><a href="/me/c/{{.Collection.Alias}}/subscribers?filter=fediverse">{{.APFollowers}}</a></td>{{end}}
{{if .EmailEnabled}}<td><a href="/me/c/{{.Collection.Alias}}/subscribers">{{.EmailSubscribers}}</a></td>{{end}}
</tr>
</table>
{{end}}

View File

@ -0,0 +1,98 @@
{{define "subscribers"}}
{{template "header" .}}
<style>
.toolbar {
text-align: right;
margin: 1em 0;
}
</style>
<div class="snug content-container {{if not .CanEmailSub}}clean{{end}}">
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
{{if .Collection.Collection}}{{template "collection-breadcrumbs" .}}{{end}}
<h1>Subscribers</h1>
{{if .Collection.Collection}}
{{template "collection-nav" .Collection}}
<nav class="pager sub">
<a href="/me/c/{{.Collection.Alias}}/subscribers" {{if eq .Filter ""}}class="selected"{{end}}>Email ({{len .EmailSubs}})</a>
<a href="/me/c/{{.Collection.Alias}}/subscribers?filter=fediverse" {{if eq .Filter "fediverse"}}class="selected"{{end}}>Followers ({{len .Followers}})</a>
</nav>
{{end}}
{{if .Flashes -}}
<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>
{{- end}}
{{ if eq .Filter "fediverse" }}
<table class="classy export">
<tr>
<th style="width: 60%">Username</th>
<th colspan="2">Since</th>
</tr>
{{if and (gt (len .Followers) 0) (not .FederationEnabled)}}
<div class="alert info">
<p><strong>Federation is disabled on this server</strong>, so followers won't receive any new posts.</p>
</div>
{{end}}
{{ if gt (len .Followers) 0 }}
{{range $el := .Followers}}
<tr>
<td><a href="{{.ActorID}}">@{{.EstimatedHandle}}</a></td>
<td>{{.CreatedFriendly}}</td>
</tr>
{{end}}
{{ else }}
<tr>
<td colspan="2">No followers yet.</td>
</tr>
{{ end }}
</table>
{{ else }}
{{if or .CanEmailSub .EmailSubs}}
{{if not .CanEmailSub}}
<div class="alert info">
<p><strong>Email subscriptions are disabled on this server</strong>, so no new emails will be sent out.</p>
</div>
{{end}}
{{if not .EmailSubsEnabled}}
<div class="alert info">
<p><strong>Email subscriptions are disabled</strong>. {{if .EmailSubs}}No new emails will be sent out.{{end}} To enable email subscriptions, turn the option on from your blog's <a href="/me/c/{{.Collection.Alias}}#updates">Customize</a> page.</p>
</div>
{{end}}
<table class="classy export">
<tr>
<th style="width: 60%">Email Address</th>
<th colspan="2">Since</th>
</tr>
{{ if .EmailSubs }}
{{range $el := .EmailSubs}}
<tr>
<td><a href="mailto:{{.Email.String}}">{{.Email.String}}</a></td>
<td>{{.SubscribedFriendly}}</td>
</tr>
{{end}}
{{ else }}
<tr>
<td colspan="2">No subscribers yet.</td>
</tr>
{{ end }}
</table>
{{end}}
{{ end }}
</div>
{{template "foot" .}}
{{template "body-end" .}}
{{end}}

View File

@ -134,3 +134,7 @@ func (u *User) IsAdmin() bool {
func (u *User) IsSilenced() bool {
return u.Status&UserSilenced != 0
}
func (u *User) IsEmailSubscriber(app *App, collID int64) bool {
return app.db.IsEmailSubscriber("", u.ID, collID)
}