├── .build.yml
├── .gitignore
├── FEDERATION.md
├── LICENSE
├── Makefile
├── README.md
├── archiving
└── archiving.go
├── auth
├── auth.go
└── cookie.go
├── cmd
└── betula
│ └── main.go
├── db
├── DB Styleguide.md
├── Ignored posts.md
├── archives.go
├── betula-meta-keys.go
├── db.go
├── migrations.go
├── queries_bookmarks.go
├── queries_bookmarks_test.go
├── queries_fediverse.go
├── queries_job.go
├── queries_misc.go
├── queries_reposts.go
├── queries_search.go
├── queries_sessions.go
├── queries_sessions_test.go
├── queries_tags.go
├── queries_tags_test.go
├── scripts
│ ├── 10.sql
│ ├── 11.sql
│ ├── 12.sql
│ ├── 13.sql
│ ├── 14.sql
│ ├── 15.sql
│ ├── 16.sql
│ ├── 17.sql
│ ├── 7.sql
│ ├── 8.sql
│ ├── 9.sql
│ └── Actual.md
└── testing.go
├── fediverse
├── activities
│ ├── accept.go
│ ├── activities.go
│ ├── announce.go
│ ├── announce_test.go
│ ├── follow.go
│ ├── guess.go
│ ├── note.go
│ ├── note_test.go
│ ├── reject.go
│ ├── testdata
│ │ ├── Create{Note} 1.json
│ │ └── Update{Note} 1.json
│ ├── undo.go
│ └── undo_test.go
├── actor.go
├── bookmark.go
├── fedisearch
│ ├── Federated Search.md
│ ├── api.go
│ ├── fedisearch.go
│ └── fedisearch_test.go
├── fediverse.go
├── signing
│ └── signing.go
└── webfinger.go
├── feeds
├── feed.go
└── feed_test.go
├── go.mod
├── go.sum
├── help
├── en
│ ├── errors.myco
│ ├── index.myco
│ ├── meta.myco
│ ├── mycomarkup.myco
│ └── search.myco
└── help.go
├── jobs
├── Adding a new job.md
├── implementations.go
├── jobs.go
└── jobtype
│ └── jobtype.go
├── myco
└── myco.go
├── readpage
├── readpage.go
├── readpage_test.go
├── testdata
│ ├── h-entry with p-name u-bookmark-of.html
│ ├── h-entry with p-name.html
│ ├── h-entry with substituted url.html
│ ├── h-feed with h-entries.html
│ ├── mycomarkup linked.html
│ ├── title none.html
│ └── title outside head.html
└── workers.go
├── roar-activities.sh
├── search
└── search.go
├── settings
├── How to add a new Setting.md
└── settings.go
├── stricks
└── stricks.go
├── test-web.sh
├── tools
├── last_seen.go
├── last_seen_test.go
└── slice.go
├── types
├── ap.go
├── archiving.go
├── types.go
└── types_test.go
└── web
├── autocompletion.js
├── bookmarklet.js
├── copytext.js
├── error-template.go
├── handlers.go
├── pix
├── favicon.png
├── favicon.svg
└── logo.svg
├── style.css
├── templates.go
├── views
├── about.gohtml
├── bookmarklet.gohtml
├── day.gohtml
├── edit-link.gohtml
├── edit-tag.gohtml
├── fedisearch.gohtml
├── feed.gohtml
├── followers.gohtml
├── following.gohtml
├── help.gohtml
├── link-form-fragment.gohtml
├── login-form.gohtml
├── logout-form.gohtml
├── my-profile.gohtml
├── paginator-fragment.gohtml
├── post-fragment.gohtml
├── post.gohtml
├── register-form.gohtml
├── remote-profile.gohtml
├── repost.gohtml
├── reposts-of.gohtml
├── save-link.gohtml
├── search.gohtml
├── sessions.gohtml
├── settings.gohtml
├── skeleton.gohtml
├── status.gohtml
├── tag.gohtml
├── tags.gohtml
└── timeline.gohtml
└── web.go
/.build.yml:
--------------------------------------------------------------------------------
1 | image: alpine/edge
2 | packages:
3 | - go
4 | - curl
5 | sources:
6 | - https://git.sr.ht/~bouncepaw/betula
7 | tasks:
8 | - test: |
9 | cd betula
10 | make test
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | betula
2 | *.betula
3 | .idea/
4 | **/.DS_Store
5 |
--------------------------------------------------------------------------------
/FEDERATION.md:
--------------------------------------------------------------------------------
1 | # Federation capabilities in Betula
2 |
3 | **This document is outdated, sorry.**
4 |
5 | Betula uses a homebrew mixture of ActivityPub and whatnot. Sometimes your system might work with Betula out of the box. Usually not. Most importantly, Betula does not implement HTTP signatures, which is a deal breaker for most other implementations. We'll have them later.
6 |
7 | This document describes all outgoing and incoming Activities. Supporting them ensures your compatibility. Betula will also try to be more standards-compliant later, but it's not a priority for now.
8 |
9 | ## User identification
10 | Every Betula is a single-user installation. Thus, Betula's URL is also its admin's URL.
11 |
12 | The inbox is found at `/inbox`. Unknown Activities are dropped.
13 |
14 | ## Verification
15 | Betula does not yet implement HTTP signatures and relies on manual resource fetching instead. HTTP signatures support may be implemented in the future.
16 |
17 | ## Repost notification
18 | Public reposts are reported to authors of original posts.
19 |
20 | A notification like this is made when Alice reposts Bob's post 42, and her repost gets number 84:
21 |
22 | ```json
23 | {
24 | "@context": "https://www.w3.org/ns/activitystreams",
25 | "type": "Announce",
26 | "actor": {
27 | "type": "Person",
28 | "id": "https://links.alice",
29 | "inbox": "https://links.alice/inbox",
30 | "name": "Alice",
31 | "preferredUsername": "alice"
32 | },
33 | "id": "https://links.alice/84",
34 | "object": "https://links.bob/42"
35 | }
36 | ```
37 |
38 | You are to verify the repost yourself. We use microformats.
39 |
40 | ## Repost cancellation
41 | If a repost is turned into a regular post or deleted, you will get a notification like this:
42 |
43 | ```json
44 | {
45 | "@context": "https://www.w3.org/ns/activitystreams",
46 | "type": "Undo",
47 | "actor": {
48 | "type": "Person",
49 | "id": "https://links.alice",
50 | "inbox": "https://links.alice/inbox",
51 | "name": "Alice",
52 | "preferredUsername": "alice"
53 | },
54 | "object": {
55 | "type": "Announce",
56 | "id": "https://links.alice/84",
57 | "actor": "https://links.alice",
58 | "object": "https://links.bob/42"
59 | }
60 | }
61 | ```
62 |
63 | You are to verify the lack of repost yourself. We use microformats.
64 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | export CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
2 | export CGO_ENABLED=1
3 |
4 | .PHONY: betula debug-run run-with-port clean test
5 |
6 | betula:
7 | go build -o betula ./cmd/betula
8 |
9 | debug-run: clean betula
10 | ./betula db.betula
11 |
12 | run-with-port: betula
13 | ./betula -port 8081 db.betula
14 |
15 | clean:
16 | rm -f betula
17 |
18 | test: clean betula
19 | go test ./db
20 | go test ./types
21 | go test ./feeds
22 | go test ./readpage
23 | go test ./fediverse/activities
24 | sh test-web.sh
25 | killall betula
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🌳 Betula, a federated personal link collection manager
2 | 
3 |
4 | [](https://hitsofcode.com/sourcehut/~bouncepaw/betula/view?branch=master)
5 |
6 | **Betula** is a single-user self-hosted bookmarking software with Fediverse support and archives.
7 |
8 | * [Website](https://betula.mycorrhiza.wiki)
9 | * [Source code](https://git.sr.ht/~bouncepaw/betula)
10 | * [Mailing list](https://lists.sr.ht/~bouncepaw/betula)
11 | * [Donate on Boosty (with devlog)](https://boosty.to/bouncepaw)
12 |
13 | ## Features
14 | * Publish bookmarks, along with optional title and description formatted with [Mycomarkup](https://mycorrhiza.wiki/help/en/mycomarkup).
15 | * Add tags to your bookmarks.
16 | * Fediverse:
17 | * You can repost posts from other Betula instances, and other software, sometimes.
18 | * Other Fediverse software such as Mastodon can follow Betula instances.
19 | * You can follow other Betulas and receive new bookmarks in your Timeline!
20 | * Make archive copies of web pages.
21 | * The whole collection is saved as a single SQLite file.
22 | * Search.
23 | * Bookmarklet.
24 | * Bookmarks can be public or private. Share only what you want to share!
25 | * Simple user interface that does not require JavaScript.
26 | * If you have JavaScript, you can use tag autocompletion.
27 | * [IndieWeb](https://indieweb.org) microformats are produced.
28 | * Simple installation: the program is one binary, the collection is one file, all configuration is done through the web interface.
29 | * Built-in documentation.
--------------------------------------------------------------------------------
/archiving/archiving.go:
--------------------------------------------------------------------------------
1 | package archiving
2 |
3 | import (
4 | "codeberg.org/bouncepaw/obelisk-ng"
5 | "context"
6 | "time"
7 | )
8 |
9 | // Archiver archives documents.
10 | type Archiver interface {
11 | // Fetch fetches an archive copy for the document identified by URL.
12 | // Returns contents, MIME-type and a possible error.
13 | Fetch(url string) ([]byte, string, error)
14 | }
15 |
16 | // ObeliskArchiver fetched archive copies using
17 | // the obelisk-ng library.
18 | type ObeliskArchiver struct {
19 | *obelisk.Archiver
20 | }
21 |
22 | func NewObeliskArchiver() *ObeliskArchiver {
23 | var a = ObeliskArchiver{
24 | Archiver: &obelisk.Archiver{
25 | Cache: nil,
26 | EnableLog: true,
27 | EnableVerboseLog: false,
28 | DisableJS: false,
29 | DisableCSS: false,
30 | DisableEmbeds: false,
31 | DisableMedias: false,
32 | RequestTimeout: time.Second * 15,
33 | MaxRetries: 3,
34 | },
35 | }
36 | a.Archiver.Validate()
37 | return &a
38 | }
39 |
40 | func (o *ObeliskArchiver) Fetch(url string) ([]byte, string, error) {
41 | return o.Archiver.Archive(context.Background(), obelisk.Request{
42 | URL: url,
43 | })
44 | }
45 |
--------------------------------------------------------------------------------
/auth/auth.go:
--------------------------------------------------------------------------------
1 | // Package auth provides you functions that let you work with auth. All state is stored in-package. The password is stored hashed, so safe enough.
2 | package auth
3 |
4 | import (
5 | "database/sql"
6 | "git.sr.ht/~bouncepaw/betula/db"
7 | "git.sr.ht/~bouncepaw/betula/settings"
8 | "golang.org/x/crypto/bcrypt"
9 | "log"
10 | "sync/atomic"
11 | )
12 |
13 | var (
14 | ready atomic.Bool
15 | )
16 |
17 | // Initialize queries the database for auth information. Call on startup. The module handles all further invocations for you.
18 | func Initialize() {
19 | ready.Store(false)
20 | var (
21 | name = db.MetaEntry[sql.NullString](db.BetulaMetaAdminUsername)
22 | pass = db.MetaEntry[sql.NullString](db.BetulaMetaAdminPasswordHash)
23 | )
24 | ready.Store(name.Valid && pass.Valid)
25 | }
26 |
27 | // Ready returns if the admin account is set up. If it is not, Betula should demand it and refuse to work until then.
28 | func Ready() bool {
29 | ready := ready.Load()
30 | if ready {
31 | return true
32 | }
33 | Initialize()
34 | return ready
35 | }
36 |
37 | // CredentialsMatch checks if the credentials match.
38 | func CredentialsMatch(name, pass string) bool {
39 | if name != settings.AdminUsername() {
40 | log.Println("Matching credentials. Name mismatches.")
41 | return false
42 | }
43 | err := bcrypt.CompareHashAndPassword(db.MetaEntry[[]byte](db.BetulaMetaAdminPasswordHash), []byte(pass))
44 | if err != nil {
45 | log.Println("Matching credentials. Password mismatches.")
46 | return false
47 | }
48 | log.Println("Credentials match.")
49 | return true
50 | }
51 |
52 | // SetCredentials sets new credentials.
53 | func SetCredentials(name, pass string) {
54 | log.Println("Setting new credentials")
55 |
56 | hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
57 | if err != nil {
58 | log.Fatalln("While hashing:", err)
59 | }
60 |
61 | db.SetCredentials(name, string(hash))
62 | Initialize()
63 | settings.Index()
64 | }
65 |
--------------------------------------------------------------------------------
/auth/cookie.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "crypto/rand"
5 | "encoding/hex"
6 | "git.sr.ht/~bouncepaw/betula/db"
7 | "git.sr.ht/~bouncepaw/betula/types"
8 | "git.sr.ht/~bouncepaw/betula/tools"
9 | "net/http"
10 | "time"
11 | )
12 |
13 | const tokenName = "betula-token"
14 |
15 | func Token(rq *http.Request) (string, error) {
16 | cookie, err := rq.Cookie(tokenName)
17 | if err != nil {
18 | return "", err
19 | }
20 | return cookie.Value, nil
21 | }
22 |
23 | func MarkCurrentSession(currentToken string, sessions []types.Session) []types.Session {
24 | for i, session := range sessions {
25 | if session.Token == currentToken {
26 | sessions[i].Current = true
27 | tools.MoveElement(sessions, i, 0)
28 | return sessions
29 | }
30 | }
31 | return sessions
32 | }
33 |
34 | // AuthorizedFromRequest is true if the user is authorized.
35 | func AuthorizedFromRequest(rq *http.Request) bool {
36 | cookie, err := rq.Cookie(tokenName)
37 | if err != nil {
38 | return false
39 | }
40 | return db.SessionExists(cookie.Value)
41 | }
42 |
43 | // LogoutFromRequest logs the user in the request out and rewrites the cookie in to an empty one.
44 | func LogoutFromRequest(w http.ResponseWriter, rq *http.Request) {
45 | cookie, err := rq.Cookie(tokenName)
46 | if err == nil {
47 | http.SetCookie(w, newCookie("", time.Unix(0, 0)))
48 | db.StopSession(cookie.Value)
49 | }
50 | }
51 |
52 | // LogInResponse logs such user in and writes a cookie for them.
53 | func LogInResponse(userAgent string, w http.ResponseWriter) {
54 | token := randomString(24)
55 | db.AddSession(token, userAgent)
56 | http.SetCookie(w, newCookie(token, time.Now().Add(365*24*time.Hour)))
57 | }
58 |
59 | func Sessions() []types.Session {
60 | return db.Sessions()
61 | }
62 |
63 | func randomString(n int) string {
64 | bytes := make([]byte, n)
65 | _, _ = rand.Read(bytes)
66 | return hex.EncodeToString(bytes)
67 | }
68 |
69 | func newCookie(val string, t time.Time) *http.Cookie {
70 | return &http.Cookie{
71 | Name: tokenName,
72 | Value: val,
73 | Expires: t,
74 | Path: "/",
75 | SameSite: http.SameSiteLaxMode,
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/cmd/betula/main.go:
--------------------------------------------------------------------------------
1 | // Command betula runs Betula, a personal link collection software.
2 | package main
3 |
4 | import (
5 | "flag"
6 | "fmt"
7 | "git.sr.ht/~bouncepaw/betula/auth"
8 | "git.sr.ht/~bouncepaw/betula/db"
9 | "git.sr.ht/~bouncepaw/betula/fediverse/activities"
10 | "git.sr.ht/~bouncepaw/betula/fediverse/signing"
11 | "git.sr.ht/~bouncepaw/betula/jobs"
12 | "git.sr.ht/~bouncepaw/betula/settings"
13 | "git.sr.ht/~bouncepaw/betula/web"
14 | _ "git.sr.ht/~bouncepaw/betula/web" // For init()
15 | "log"
16 | "log/slog"
17 | "os"
18 | "path/filepath"
19 |
20 | _ "github.com/mattn/go-sqlite3"
21 | )
22 |
23 | func main() {
24 | slog.SetLogLoggerLevel(slog.LevelDebug)
25 | var port uint
26 | var versionFlag bool
27 |
28 | flag.BoolVar(&versionFlag, "version", false, "Print version and exit.")
29 | flag.UintVar(&port, "port", 0, "Port number. "+
30 | "The value gets written to a database file and is used immediately.")
31 | flag.Usage = func() {
32 | _, _ = fmt.Fprintf(
33 | flag.CommandLine.Output(),
34 | "Usage: %s DB_PATH.betula\n",
35 | os.Args[0],
36 | )
37 | flag.PrintDefaults()
38 | }
39 | flag.Parse()
40 |
41 | if versionFlag {
42 | fmt.Printf("Betula %s\n", "v1.4.0")
43 | return
44 | }
45 |
46 | if len(flag.Args()) < 1 {
47 | log.Fatalln("Pass a database file name!")
48 | }
49 |
50 | filename, err := filepath.Abs(flag.Arg(0))
51 | if err != nil {
52 | log.Fatalln(err)
53 | }
54 |
55 | fmt.Println("Hello Betula!")
56 |
57 | db.Initialize(filename)
58 | defer db.Finalize()
59 | settings.Index()
60 | auth.Initialize()
61 | // If the user provided a non-zero port, use it. Write it to the DB. It will be picked up later by settings.Index(). If they did not provide such a port, whatever, settings.Index() will figure something out 🙏
62 | if port > 0 {
63 | settings.WritePort(port)
64 | }
65 | signing.EnsureKeysFromDatabase()
66 | activities.GenerateBetulaActor()
67 | go jobs.ListenAndWhisper()
68 | web.StartServer()
69 | }
70 |
--------------------------------------------------------------------------------
/db/DB Styleguide.md:
--------------------------------------------------------------------------------
1 | # Betula database styleguide
2 | ## SQL formatting
3 | There is no chosen SQL code style yet. Just keep it close to what Bouncepaw might consider sane.
4 |
5 | ## `db` package
6 | *All* database manipulations are encapsulated in the `db` package. Basically every possible way to use it gets a separate function. We don't have special types for the databases, the state is package-wide. Since we only have one database in use at any given moment, it's alright.
7 |
8 | **UPD.** Special types are being incorporated now.
9 |
10 | Be careful with migrations.
11 |
12 | The functions should probably have the SQL code as inline strings. If you are to move them to constants, probably name them `q`. If you have multiple of them in one function, call them `qFoo` and `qBar`. Bouncepaw likes this q prefix, it's cute.
13 |
14 | If you write the tests, that would be nice.
--------------------------------------------------------------------------------
/db/Ignored posts.md:
--------------------------------------------------------------------------------
1 | # On ignoring posts
2 | Some posts are not seen. Deleted posts are never seen. Private posts are not seen by the unauthorized. This document lists some techniques on how to provide this ignoring in the SQL queries to Betula. There is currently no consensus on which approach is the best. Judge.
3 |
4 | ## With ignored posts
5 | This is the first approach we came up with.
6 |
7 | ```sqlite
8 | with
9 | IgnoredPosts as (
10 | -- Ignore deleted posts always
11 | select ID from Posts where DeletionTime is not null
12 | union
13 | -- Ignore private posts if so desired
14 | select ID from Posts where Visibility = 0 and not ?
15 | )
16 | select
17 | CatName,
18 | count(PostID)
19 | from
20 | CategoriesToPosts
21 | where
22 | PostID not in IgnoredPosts
23 | group by
24 | CatName;
25 | ```
26 |
27 | 1. Authorization flag is passed to `?`. It is true if authorized.
28 | 2. Take deleted posts.
29 | 3. Take private posts if not authorized.
30 | 4. Union 2 and 3, these are ignored posts.
31 | 5. Filter them out in your query.
32 |
33 | ### Another approach
34 | We can use `JOIN`.
35 | Note that it is more preferable to filter the posts first and then join the tables.
36 |
37 | ```sqlite
38 | select
39 | CatName,
40 | count(PostID)
41 | from
42 | CategoriesToPosts
43 | inner join
44 | (select ID from main.Posts where DeletionTime is null and (Visibility = 1 or ?))
45 | as
46 | Filtered
47 | on
48 | CategoriesToPosts.PostID = Filtered.ID
49 | group by
50 | CatName;
51 | ```
52 |
53 | ## With non-ignored posts
54 | This is the positive version of the previous approach. Not used now.
55 |
56 | ## Short condition
57 | This one does not use the `with` expression.
58 |
59 | ```sqlite
60 | select min(CreationTime)
61 | from Posts
62 | where DeletionTime is null and (Visibility = 1 or ?);
63 | ```
64 |
65 | The `(Visibility = 1 or ?)` part needs some explanation. Consider the following table:
66 |
67 | | Authorized? | Public? | Should be shown? |
68 | | ----------- | ------- | ---------------- |
69 | | 0 | 0 | 0 |
70 | | 0 | 1 | 1 |
71 | | 1 | 0 | 1 |
72 | | 1 | 1 | 1 |
73 |
74 | This table is the logical table for OR. One can also think about the logical implication and come up with a funnier way of ignoring posts.
--------------------------------------------------------------------------------
/db/archives.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "git.sr.ht/~bouncepaw/betula/types"
7 | "log/slog"
8 | )
9 |
10 | type ArtifactsRepo interface {
11 | Fetch(string) (*types.Artifact, error)
12 | }
13 |
14 | type dbArtifactsRepo struct{}
15 |
16 | func (repo *dbArtifactsRepo) Fetch(id string) (*types.Artifact, error) {
17 | var artifact = types.Artifact{
18 | ID: id,
19 | }
20 | var row = db.QueryRow(`select MimeType, Data, IsGzipped, length(Data) from Artifacts where ID = ?`, id)
21 | var err = row.Scan(&artifact.MimeType, &artifact.Data, &artifact.IsGzipped, &artifact.Size)
22 | return &artifact, err
23 | }
24 |
25 | func NewArtifactsRepo() ArtifactsRepo {
26 | return &dbArtifactsRepo{}
27 | }
28 |
29 | type ArchivesRepo interface {
30 | // Store stores a new archive for the given bookmark with
31 | // the given artifact. It returns id of the new archive.
32 | Store(bookmarkID int64, artifact *types.Artifact) (int64, error)
33 | FetchForBookmark(bookmarkID int64) ([]types.Archive, error)
34 | DeleteArchive(archiveID int64) error
35 | }
36 |
37 | type dbArchivesRepo struct{}
38 |
39 | func (repo *dbArchivesRepo) Store(bookmarkID int64, artifact *types.Artifact) (int64, error) {
40 | var tx, err = db.BeginTx(context.Background(), nil)
41 | if err != nil {
42 | return 0, err
43 | }
44 |
45 | // If the hash (ID) is taken already, it means we already have such an
46 | // artifact in our database. OK, whatever, that's why we `ignore`
47 | // in the request below. The archive will reuse the old artifact then.
48 | _, err = tx.Exec(`
49 | insert or ignore into Artifacts (ID, MimeType, Data, IsGzipped)
50 | values (?, ?, ?, ?)`,
51 | artifact.ID, artifact.MimeType, artifact.Data, artifact.IsGzipped)
52 | if err != nil {
53 | return 0, errors.Join(err, tx.Rollback())
54 | }
55 |
56 | var newArchiveID int64
57 | var row = tx.QueryRow(
58 | `insert into Archives (BookmarkID, ArtifactID) values (?, ?) returning ID`,
59 | bookmarkID, artifact.ID)
60 | err = row.Scan(&newArchiveID)
61 | if err != nil {
62 | return 0, errors.Join(err, tx.Rollback())
63 | }
64 |
65 | return newArchiveID, tx.Commit()
66 | }
67 |
68 | func (repo *dbArchivesRepo) DeleteArchive(archiveID int64) error {
69 | var tx, err = db.BeginTx(context.Background(), nil)
70 | if err != nil {
71 | return err
72 | }
73 |
74 | // Artifacts might be reused, so after deleting the archive,
75 | // the corresponding artifact is to be deleted only if
76 | // no other archives refer it.
77 |
78 | var artifactID string
79 | var row = tx.QueryRow(
80 | `delete from Archives where ID = ? returning ArtifactID`,
81 | archiveID)
82 | if err = row.Scan(&artifactID); err != nil {
83 | return errors.Join(err, tx.Rollback())
84 | }
85 |
86 | var artifactUsageCount int64
87 | row = tx.QueryRow(
88 | `select count(*) from Archives where ArtifactID = ?`,
89 | artifactID)
90 | if err = row.Scan(&artifactUsageCount); err != nil {
91 | return errors.Join(err, tx.Rollback())
92 | }
93 |
94 | if artifactUsageCount == 0 {
95 | _, err = tx.Exec(`delete from Artifacts where ID = ?`, artifactID)
96 | if err != nil {
97 | return errors.Join(err, tx.Rollback())
98 | }
99 | }
100 |
101 | return tx.Commit()
102 | }
103 |
104 | func (repo *dbArchivesRepo) FetchForBookmark(bookmarkID int64) ([]types.Archive, error) {
105 | var archives []types.Archive
106 | // Not fetching the binary data
107 | var rows, err = db.Query(`
108 | select
109 | arc.ID, arc.SavedAt,
110 | art.ID, art.MimeType, art.IsGzipped, length(art.Data)
111 | from
112 | Archives arc
113 | join
114 | Artifacts art
115 | on
116 | arc.ArtifactID = art.ID
117 | where
118 | arc.BookmarkID = ?
119 | order by
120 | arc.SavedAt desc
121 | `, bookmarkID)
122 | if err != nil {
123 | return nil, err
124 | }
125 |
126 | for rows.Next() {
127 | var (
128 | archive types.Archive
129 | artifact types.Artifact
130 | )
131 | err = rows.Scan(&archive.ID, &archive.SavedAt,
132 | &artifact.ID, &artifact.MimeType, &artifact.IsGzipped, &artifact.Size)
133 | if err != nil {
134 | return nil, err
135 | }
136 |
137 | archive.Artifact = artifact
138 | archives = append(archives, archive)
139 | }
140 |
141 | slog.Debug("Fetched archives for bookmark",
142 | "bookmarkID", bookmarkID,
143 | "archivesLen", len(archives))
144 | return archives, nil
145 | }
146 |
147 | func NewArchivesRepo() ArchivesRepo {
148 | return &dbArchivesRepo{}
149 | }
150 |
--------------------------------------------------------------------------------
/db/betula-meta-keys.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | // BetulaMetaKey is key from the BetulaMeta table.
4 | type BetulaMetaKey string
5 |
6 | const (
7 | BetulaMetaAdminUsername BetulaMetaKey = "Admin username"
8 | BetulaMetaAdminPasswordHash BetulaMetaKey = "Admin password hash"
9 | BetulaMetaNetworkHost BetulaMetaKey = "Network hostname"
10 | BetulaMetaNetworkPort BetulaMetaKey = "Network port"
11 | BetulaMetaSiteTitle BetulaMetaKey = "Site title HTML"
12 | BetulaMetaSiteName BetulaMetaKey = "Site name plaintext"
13 | BetulaMetaSiteDescription BetulaMetaKey = "Site description Mycomarkup"
14 | BetulaMetaSiteURL BetulaMetaKey = "WWW URL"
15 | BetulaMetaCustomCSS BetulaMetaKey = "Custom CSS"
16 | BetulaMetaPrivateKey BetulaMetaKey = "Private key PEM"
17 | BetulaMetaEnableFederation BetulaMetaKey = "Federation enabled"
18 | BetulaMetaPublicCustomJS BetulaMetaKey = "Public custom JS"
19 | BetulaMetaPrivateCustomJS BetulaMetaKey = "Private custom JS"
20 | )
21 |
--------------------------------------------------------------------------------
/db/db.go:
--------------------------------------------------------------------------------
1 | // Package db encapsulates all used queries to the database.
2 | //
3 | // Do not forget to Initialize and Finalize.
4 | //
5 | // All functions in this package might crash vividly.
6 | package db
7 |
8 | import (
9 | "database/sql"
10 | "log"
11 |
12 | _ "github.com/mattn/go-sqlite3"
13 | )
14 |
15 | // Initialize opens a SQLite3 database with the given filename. The connection is encapsulated, you cannot access the database directly, you are to use the functions provided by the package.
16 | func Initialize(filename string) {
17 | var err error
18 |
19 | db, err = sql.Open("sqlite3", filename+"?cache=shared")
20 | if err != nil {
21 | log.Fatalln(err)
22 | }
23 |
24 | db.SetMaxOpenConns(1)
25 | handleMigrations()
26 | }
27 |
28 | // Finalize closes the connection with the database.
29 | func Finalize() {
30 | err := db.Close()
31 | if err != nil {
32 | log.Fatalln(err)
33 | }
34 | }
35 |
36 | var (
37 | db *sql.DB
38 | )
39 |
40 | // Utility functions
41 |
42 | func mustExec(query string, args ...any) {
43 | _, err := db.Exec(query, args...)
44 | if err != nil {
45 | log.Fatalln(err)
46 | }
47 | }
48 |
49 | func mustQuery(query string, args ...any) *sql.Rows {
50 | rows, err := db.Query(query, args...)
51 | if err != nil {
52 | log.Fatalln(err)
53 | }
54 | return rows
55 | }
56 |
57 | func mustScan(rows *sql.Rows, dest ...any) {
58 | err := rows.Scan(dest...)
59 | if err != nil {
60 | log.Fatalln(err)
61 | }
62 | }
63 |
64 | func querySingleValue[T any](query string, vals ...any) T {
65 | rows := mustQuery(query, vals...)
66 | var res T
67 | for rows.Next() { // Do 0 or 1 times
68 | mustScan(rows, &res)
69 | break
70 | }
71 | _ = rows.Close()
72 | return res
73 | }
74 |
--------------------------------------------------------------------------------
/db/queries_bookmarks_test.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "testing"
5 |
6 | "git.sr.ht/~bouncepaw/betula/types"
7 | )
8 |
9 | func TestBookmarkCount(t *testing.T) {
10 | InitInMemoryDB()
11 | resAuthed := BookmarkCount(true)
12 | if resAuthed != 2 {
13 | t.Errorf("Wrong authorized LinkCount, got %d", resAuthed)
14 | }
15 | resAnon := BookmarkCount(false)
16 | if resAnon != 1 {
17 | t.Errorf("Wrong unauthorized LinkCount, got %d", resAnon)
18 | }
19 | }
20 |
21 | func TestAddPost(t *testing.T) {
22 | InitInMemoryDB()
23 | post := types.Bookmark{
24 | CreationTime: "2023-03-18",
25 | Tags: []types.Tag{
26 | {Name: "cat"},
27 | {Name: "dog"},
28 | },
29 | URL: "https://betula.mycorrhiza.wiki",
30 | Title: "Betula",
31 | Description: "",
32 | Visibility: types.Public,
33 | }
34 | InsertBookmark(post)
35 | if BookmarkCount(true) != 3 {
36 | t.Errorf("Faulty AddPost")
37 | }
38 | }
39 |
40 | func TestRandomBookmarks(t *testing.T) {
41 | InitInMemoryDB()
42 | MoreTestingBookmarks()
43 |
44 | cases := []struct {
45 | authorized bool
46 | n uint
47 | }{
48 | {true, 20},
49 | {false, 20},
50 | }
51 |
52 | for _, tc := range cases {
53 | bookmarks, total := RandomBookmarks(tc.authorized, tc.n)
54 | if len(bookmarks) != int(total) {
55 | t.Errorf("Length of bookmarks does not match the total count")
56 | }
57 | creationTime := bookmarks[0].CreationTime
58 | for _, bookmark := range bookmarks[1:] {
59 | if bookmark.CreationTime > creationTime {
60 | t.Errorf("Bookmarks not in correct order")
61 | }
62 | creationTime = bookmark.CreationTime
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/db/queries_job.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "git.sr.ht/~bouncepaw/betula/jobs/jobtype"
5 | "log"
6 | )
7 |
8 | // PlanJob puts a new job into the Jobs table and returns the id of the new job.
9 | func PlanJob(job jobtype.Job) int64 {
10 | const q = `insert into Jobs (Category, Payload) values (?, ?)`
11 |
12 | // mustExec not used because res needed
13 | res, err := db.Exec(q, job.Category, job.Payload)
14 | if err != nil {
15 | log.Fatalln(err)
16 | }
17 |
18 | // It never fails
19 | // https://github.com/mattn/go-sqlite3/blob/v1.14.17/sqlite3.go#L2008
20 | id, _ := res.LastInsertId()
21 | return id
22 | }
23 |
24 | // DropJob removes the job specified by id from the database.
25 | // Call after the job is done.
26 | func DropJob(id int64) {
27 | mustExec(`delete from Jobs where ID = ?`, id)
28 | }
29 |
30 | // LoadAllJobs reads all jobs in the database. Call on startup once.
31 | func LoadAllJobs() (jobs []jobtype.Job) {
32 | const q = `select id, category, payload from Jobs`
33 | rows := mustQuery(q)
34 | for rows.Next() {
35 | var job jobtype.Job
36 | mustScan(rows, &job.ID, &job.Category, &job.Payload)
37 | jobs = append(jobs, job)
38 | }
39 | return jobs
40 | }
41 |
--------------------------------------------------------------------------------
/db/queries_misc.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "database/sql"
5 | "git.sr.ht/~bouncepaw/betula/types"
6 | "log"
7 | "time"
8 | )
9 |
10 | func MetaEntry[T any](key BetulaMetaKey) T {
11 | const q = `select Value from BetulaMeta where Key = ? limit 1;`
12 | return querySingleValue[T](q, key)
13 | }
14 |
15 | func SetMetaEntry[T any](key BetulaMetaKey, val T) {
16 | const q = `insert or replace into BetulaMeta values (?, ?);`
17 | mustExec(q, key, val)
18 | }
19 |
20 | func OldestTime(authorized bool) *time.Time {
21 | const q = `
22 | select min(CreationTime)
23 | from Bookmarks
24 | where DeletionTime is null and (Visibility = 1 or ?);
25 | `
26 | stamp := querySingleValue[sql.NullString](q, authorized)
27 | if stamp.Valid {
28 | val, err := time.Parse(types.TimeLayout, stamp.String)
29 | if err != nil {
30 | log.Fatalln(err)
31 | }
32 | return &val
33 | }
34 | return nil
35 | }
36 |
37 | func NewestTime(authorized bool) *time.Time {
38 | const q = `
39 | select max(CreationTime)
40 | from Bookmarks
41 | where DeletionTime is null and (Visibility = 1 or ?);
42 | `
43 | stamp := querySingleValue[sql.NullString](q, authorized)
44 | if stamp.Valid {
45 | val, err := time.Parse(types.TimeLayout, stamp.String)
46 | if err != nil {
47 | log.Fatalln(err)
48 | }
49 | return &val
50 | }
51 | return nil
52 | }
53 |
--------------------------------------------------------------------------------
/db/queries_reposts.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "git.sr.ht/~bouncepaw/betula/types"
5 | "log"
6 | "time"
7 | )
8 |
9 | // RepostsOf returns all reposts known about the specified bookmark.
10 | func RepostsOf(id int) (reposts []types.RepostInfo, err error) {
11 | rows := mustQuery(`select RepostURL, ReposterName, RepostedAt from KnownReposts where PostID = ?`, id)
12 | for rows.Next() {
13 | var repost types.RepostInfo
14 | var timestamp string
15 | mustScan(rows, &repost.URL, &repost.Name, ×tamp)
16 | repost.Timestamp, err = time.Parse(types.TimeLayout, timestamp)
17 | if err != nil {
18 | log.Printf("When reading tags for bookmark no. %d: %s\n", id, err)
19 | }
20 | reposts = append(reposts, repost)
21 | }
22 | return reposts, nil
23 | }
24 |
25 | func CountRepostsOf(id int) int {
26 | const q = `select count(*) from KnownReposts where PostID = ?;`
27 | return querySingleValue[int](q, id)
28 | }
29 |
30 | func SaveRepost(bookmarkID int, repost types.RepostInfo) {
31 | const q = `
32 | insert into KnownReposts (RepostURL, PostID, ReposterName)
33 | values (?, ?, ?)
34 | on conflict do nothing`
35 | mustExec(q, repost.URL, bookmarkID, repost.Name)
36 | }
37 |
38 | func DeleteRepost(bookmarkID int, repostURL string) {
39 | mustExec(`delete from KnownReposts where RepostURL = ? and PostID = ?`, repostURL, bookmarkID)
40 | }
41 |
--------------------------------------------------------------------------------
/db/queries_search.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "sort"
5 | "strings"
6 |
7 | "git.sr.ht/~bouncepaw/betula/types"
8 | )
9 |
10 | func SearchOffset(text string, includedTags []string, excludedTags []string, offset, limit uint) (results []types.Bookmark, totalResults uint) {
11 | text = strings.ToLower(text)
12 | sort.Strings(includedTags)
13 | sort.Strings(excludedTags)
14 |
15 | const q = `
16 | select ID, URL, Title, Description, Visibility, CreationTime, RepostOf
17 | from Bookmarks
18 | where DeletionTime is null and Visibility = 1
19 | order by CreationTime desc
20 | `
21 | rows := mustQuery(q)
22 |
23 | var unfilteredBookmarks []types.Bookmark
24 | for rows.Next() {
25 | var b types.Bookmark
26 | mustScan(rows, &b.ID, &b.URL, &b.Title, &b.Description, &b.Visibility, &b.CreationTime, &b.RepostOf)
27 | unfilteredBookmarks = append(unfilteredBookmarks, b)
28 | }
29 |
30 | var i uint = 0
31 | var ignoredBookmarks uint = 0
32 | bookmarksToIgnore := offset
33 |
34 | for _, post := range unfilteredBookmarks {
35 | if !textOK(post, text) {
36 | continue
37 | }
38 |
39 | post.Tags = TagsForBookmarkByID(post.ID)
40 | if !tagsOK(post.Tags, includedTags, excludedTags) {
41 | continue
42 | }
43 |
44 | isRepost := post.RepostOf != nil
45 | if isRepost {
46 | continue // for now
47 | }
48 |
49 | totalResults++
50 | if ignoredBookmarks >= bookmarksToIgnore && i < limit {
51 | results = append(results, post)
52 | i++
53 | } else {
54 | ignoredBookmarks++
55 | }
56 | }
57 | return results, totalResults
58 | }
59 |
60 | func Search(text string, includedTags []string, excludedTags []string, repostsOnly, authorized bool, page uint) (results []types.Bookmark, totalResults uint) {
61 | text = strings.ToLower(text)
62 | sort.Strings(includedTags)
63 | sort.Strings(excludedTags)
64 |
65 | const q = `
66 | select ID, URL, Title, Description, Visibility, CreationTime, RepostOf
67 | from Bookmarks
68 | where DeletionTime is null and (Visibility = 1 or ?)
69 | order by CreationTime desc
70 | `
71 | rows := mustQuery(q, authorized)
72 |
73 | var unfilteredBookmarks []types.Bookmark
74 | for rows.Next() {
75 | var b types.Bookmark
76 | mustScan(rows, &b.ID, &b.URL, &b.Title, &b.Description, &b.Visibility, &b.CreationTime, &b.RepostOf)
77 | unfilteredBookmarks = append(unfilteredBookmarks, b)
78 | }
79 |
80 | var i uint = 0
81 | var ignoredBookmarks uint = 0
82 | bookmarksToIgnore := (page - 1) * types.BookmarksPerPage
83 |
84 | // ‘Say, Bouncepaw, why did not you implement tag inclusion/exclusion
85 | // part in SQL directly?’, some may ask.
86 | // ‘I did, and it was not worth it’, so I would respond.
87 | //
88 | // Addendum: I tried to make case-insensitive search in SQL too, and
89 | // failed loudly. Now all the search is done in Go. Per aspera ad
90 | // astra.
91 | //
92 | // We can't even parallelize it.
93 | for _, post := range unfilteredBookmarks {
94 | if !textOK(post, text) {
95 | continue
96 | }
97 |
98 | post.Tags = TagsForBookmarkByID(post.ID)
99 | if !tagsOK(post.Tags, includedTags, excludedTags) {
100 | continue
101 | }
102 |
103 | isRepost := post.RepostOf != nil
104 | if !isRepost && repostsOnly {
105 | continue
106 | }
107 |
108 | totalResults++
109 | if ignoredBookmarks >= bookmarksToIgnore && i < types.BookmarksPerPage {
110 | results = append(results, post)
111 | i++
112 | } else {
113 | ignoredBookmarks++
114 | }
115 | }
116 | return results, totalResults
117 | }
118 |
119 | // true if keep, false if discard
120 | func textOK(post types.Bookmark, text string) bool {
121 | return strings.Contains(strings.ToLower(post.Title), text) ||
122 | strings.Contains(strings.ToLower(post.Description), text) ||
123 | strings.Contains(strings.ToLower(post.URL), text)
124 | }
125 |
126 | // true if keep, false if discard. All slices are sorted.
127 | func tagsOK(postTags []types.Tag, includedTags, excludedTags []string) bool {
128 | J, K := len(includedTags), len(excludedTags)
129 | j, k := 0, 0
130 | includeMask := make([]bool, J)
131 | for _, postTag := range postTags {
132 | name := postTag.Name
133 | switch {
134 | case k < K && excludedTags[k] == name:
135 | return false
136 | case j < J && includedTags[j] == name:
137 | includeMask[j] = true
138 | j++
139 | continue
140 | }
141 |
142 | for j < J && includedTags[j] < name {
143 | j++
144 | }
145 |
146 | for k < K && excludedTags[k] < name {
147 | k++
148 | }
149 | }
150 |
151 | for _, marker := range includeMask {
152 | if marker == false {
153 | return false
154 | }
155 | }
156 | return true
157 | }
158 |
--------------------------------------------------------------------------------
/db/queries_sessions.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "git.sr.ht/~bouncepaw/betula/tools"
5 | ua "github.com/mileusna/useragent"
6 | "time"
7 |
8 | "git.sr.ht/~bouncepaw/betula/types"
9 | )
10 |
11 | func AddSession(token, userAgent string) {
12 | mustExec(`insert into Sessions(Token, UserAgent) values (?, ?);`, token, userAgent)
13 | }
14 |
15 | func SessionExists(token string) (has bool) {
16 | rows := mustQuery(`select exists(select 1 from Sessions where Token = ?);`, token)
17 | rows.Next()
18 | mustScan(rows, &has)
19 | _ = rows.Close()
20 | return has
21 | }
22 |
23 | func StopSession(token string) {
24 | mustExec(`delete from Sessions where Token = ?;`, token)
25 | }
26 |
27 | func StopAllSessions(excludeToken string) {
28 | mustExec(`delete from Sessions where Token <> ?;`, excludeToken)
29 | }
30 |
31 | func SetCredentials(name, hash string) {
32 | mustExec(`
33 | insert or replace into BetulaMeta values
34 | ('Admin username', ?),
35 | ('Admin password hash', ?);
36 | `, name, hash)
37 | }
38 |
39 | func Sessions() (sessions []types.Session) {
40 | rows := mustQuery(`select Token, CreationTime, coalesce(UserAgent, '') from Sessions`)
41 | for rows.Next() {
42 | var err error
43 | var timestamp string
44 | var creationTime time.Time
45 | var session types.Session
46 | var userAgent string
47 |
48 | mustScan(rows, &session.Token, ×tamp, &userAgent)
49 | session.UserAgent = ua.Parse(userAgent)
50 | creationTime, err = time.Parse(types.TimeLayout, timestamp)
51 | if err != nil {
52 | creationTime, err = time.Parse(types.TimeLayout+"Z07:00", timestamp)
53 | if err != nil {
54 | continue
55 | }
56 | }
57 | session.LastSeen = tools.LastSeen(creationTime, time.Now())
58 | sessions = append(sessions, session)
59 | }
60 | return sessions
61 | }
62 |
--------------------------------------------------------------------------------
/db/queries_sessions_test.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import "testing"
4 |
5 | // testing AddSession, SessionExists, StopSession
6 | func TestSessionOps(t *testing.T) {
7 | InitInMemoryDB()
8 | token := pufferfish
9 | AddSession(token, "")
10 | if !SessionExists(token) {
11 | t.Errorf("Existing token not found")
12 | }
13 | StopSession(token)
14 | if SessionExists(token) {
15 | t.Errorf("Non-existent token found")
16 | }
17 | }
18 |
19 | func TestSetCredentials(t *testing.T) {
20 | InitInMemoryDB()
21 | SetCredentials(pufferfish, tropicfish)
22 | if MetaEntry[string](BetulaMetaAdminUsername) != pufferfish {
23 | t.Errorf("Wrong username returned")
24 | }
25 | if MetaEntry[string](BetulaMetaAdminPasswordHash) != tropicfish {
26 | t.Errorf("Wrong password hash returned")
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/db/queries_tags.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "git.sr.ht/~bouncepaw/betula/types"
5 | )
6 |
7 | func deleteTagDescription(tagName string) {
8 | mustExec(`delete from TagDescriptions where TagName = ?`, tagName)
9 | }
10 |
11 | func SetTagDescription(tagName string, description string) {
12 | const q = `
13 | replace into TagDescriptions (TagName, Description)
14 | values (?, ?);
15 | `
16 | if description == "" {
17 | deleteTagDescription(tagName)
18 | } else {
19 | mustExec(q, tagName, description)
20 | }
21 | }
22 |
23 | func DeleteTag(tagName string) {
24 | deleteTagDescription(tagName)
25 | mustExec(`delete from TagsToPosts where TagName = ?`, tagName)
26 | }
27 |
28 | func DescriptionForTag(tagName string) (myco string) {
29 | rows := mustQuery(`select Description from TagDescriptions where TagName = ?`, tagName)
30 | for rows.Next() { // 0 or 1
31 | mustScan(rows, &myco)
32 | break
33 | }
34 | _ = rows.Close()
35 |
36 | return myco
37 | }
38 |
39 | // TagCount counts how many tags there are available to the user.
40 | func TagCount(authorized bool) (count uint) {
41 | q := `
42 | select
43 | count(distinct TagName)
44 | from
45 | TagsToPosts
46 | inner join
47 | (select ID from Bookmarks where DeletionTime is null and (Visibility = 1 or ?))
48 | as
49 | Filtered
50 | on
51 | TagsToPosts.PostID = Filtered.ID
52 | `
53 | rows := mustQuery(q, authorized)
54 | rows.Next()
55 | mustScan(rows, &count)
56 | _ = rows.Close()
57 | return count
58 | }
59 |
60 | // Tags returns all tags found on bookmarks one has access to. They all have BookmarkCount set to a non-zero value.
61 | func Tags(authorized bool) (tags []types.Tag) {
62 | q := `
63 | select
64 | TagName,
65 | count(PostID)
66 | from
67 | TagsToPosts
68 | inner join
69 | (select ID from Bookmarks where DeletionTime is null and (Visibility = 1 or ?))
70 | as
71 | Filtered
72 | on
73 | TagsToPosts.PostID = Filtered.ID
74 | group by
75 | TagName;
76 | `
77 | rows := mustQuery(q, authorized)
78 | for rows.Next() {
79 | var tag types.Tag
80 | mustScan(rows, &tag.Name, &tag.BookmarkCount)
81 | tags = append(tags, tag)
82 | }
83 | return tags
84 | }
85 |
86 | func TagExists(tagName string) (has bool) {
87 | const q = `select exists(select 1 from TagsToPosts where TagName = ?);`
88 | rows := mustQuery(q, tagName)
89 | rows.Next()
90 | mustScan(rows, &has)
91 | _ = rows.Close()
92 | return has
93 | }
94 |
95 | func RenameTag(oldTagName, newTagName string) {
96 | const q = `
97 | update TagsToPosts
98 | set TagName = ?
99 | where TagName = ?;
100 | `
101 | mustExec(q, newTagName, oldTagName)
102 | }
103 |
104 | func SetTagsFor(bookmarkID int, tags []types.Tag) {
105 | mustExec(`delete from TagsToPosts where PostID = ?;`, bookmarkID)
106 |
107 | for _, tag := range tags {
108 | if tag.Name == "" {
109 | continue
110 | }
111 | mustExec(`insert into TagsToPosts (TagName, PostID) values (?, ?);`, tag.Name, bookmarkID)
112 | }
113 | }
114 |
115 | func TagsForBookmarkByID(id int) (tags []types.Tag) {
116 | rows := mustQuery(`
117 | select distinct TagName
118 | from TagsToPosts
119 | where PostID = ?
120 | order by TagName;
121 | `, id)
122 | for rows.Next() {
123 | var tag types.Tag
124 | mustScan(rows, &tag.Name)
125 | tags = append(tags, tag)
126 | }
127 | return tags
128 | }
129 |
130 | func getTagsForManyBookmarks(bookmarks []types.Bookmark) []types.Bookmark {
131 | for i, post := range bookmarks {
132 | post.Tags = TagsForBookmarkByID(post.ID)
133 | bookmarks[i] = post
134 | }
135 | return bookmarks
136 | }
137 |
--------------------------------------------------------------------------------
/db/queries_tags_test.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "git.sr.ht/~bouncepaw/betula/types"
5 | "testing"
6 | )
7 |
8 | func initInMemoryTags() {
9 | InitInMemoryDB()
10 | q := `
11 | insert into TagsToPosts (TagName, PostID) values
12 | ('octopus', 1),
13 | ('flounder', 2),
14 | ('flounder', 3);
15 | `
16 | mustExec(q)
17 | }
18 |
19 | func TestTags(t *testing.T) {
20 | initInMemoryTags()
21 | tagsWithRights := Tags(true)
22 | if len(tagsWithRights) != 2 {
23 | t.Errorf("Wrong authorized categories count")
24 | }
25 | tagsWithoutRights := Tags(false)
26 | if len(tagsWithoutRights) != 1 {
27 | t.Errorf("Wrong unauthorized categories count")
28 | }
29 | }
30 |
31 | func TestDescriptions(t *testing.T) {
32 | initInMemoryTags()
33 |
34 | desc := "Octopi have 8 legs."
35 | SetTagDescription("octopus", desc)
36 | if DescriptionForTag("octopus") != desc {
37 | t.Errorf("Octopus has wrong description: %s", DescriptionForTag("octopus"))
38 | }
39 |
40 | if DescriptionForTag("flounder") != "" {
41 | t.Errorf("Flound has a description: %s", DescriptionForTag("flounder"))
42 | }
43 | }
44 |
45 | func TestDeleteTagDescription(t *testing.T) {
46 | initInMemoryTags()
47 |
48 | desc := "Octopi have 8 legs."
49 | SetTagDescription("octopus", desc)
50 | deleteTagDescription("octopus")
51 |
52 | if DescriptionForTag("octopus") != "" {
53 | t.Errorf("Octopus has wrong description: %s", DescriptionForTag("octopus"))
54 | }
55 | }
56 |
57 | func TestDeleteTag(t *testing.T) {
58 | initInMemoryTags()
59 |
60 | desc := "Flounder has no legs."
61 | SetTagDescription("flounder", desc)
62 | DeleteTag("flounder")
63 |
64 | if TagExists("flounder") {
65 | t.Errorf("Faulty deletion flounder")
66 | }
67 | if DescriptionForTag("flounder") != "" {
68 | t.Errorf("Flounder has wrong description: %s", DescriptionForTag("flounder"))
69 | }
70 | }
71 |
72 | func TestTagExists(t *testing.T) {
73 | initInMemoryTags()
74 |
75 | if !TagExists("flounder") {
76 | t.Errorf("Flounder does not exist")
77 | }
78 | if TagExists("orca") {
79 | t.Errorf("Orca exists")
80 | }
81 | }
82 |
83 | func TestRenameTag(t *testing.T) {
84 | initInMemoryTags()
85 |
86 | RenameTag("flounder", "orca")
87 | cats := Tags(true)
88 | if len(cats) != 2 {
89 | t.Errorf("Faulty renaming from Flounder to Orca")
90 | }
91 |
92 | RenameTag("orca", "octopus")
93 | cats = Tags(true)
94 | if len(cats) != 1 {
95 | t.Errorf("Faulty merging orca into octopus")
96 | }
97 | }
98 |
99 | // tests SetTagsFor and TagsForBookmarkByID
100 | func TestPostTags(t *testing.T) {
101 | initInMemoryTags()
102 | tags := []types.Tag{
103 | {Name: "salmon"},
104 | {Name: "carp"},
105 | }
106 | SetTagsFor(2, tags)
107 |
108 | tags = TagsForBookmarkByID(2)
109 | if len(tags) != 2 {
110 | t.Errorf("Faulty tag saving")
111 | }
112 | }
113 |
114 | func TestTagCount(t *testing.T) {
115 | initInMemoryTags()
116 | if TagCount(true) != 2 {
117 | t.Errorf("Wrong authorized categories count")
118 | }
119 | if TagCount(false) != 1 {
120 | t.Errorf("Wrong unauthorized categories count")
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/db/scripts/10.sql:
--------------------------------------------------------------------------------
1 | -- Storing these things:
2 | -- https://docs.joinmastodon.org/spec/activitypub/#publicKey
3 | create table PublicKeys (
4 | ID text not null primary key,
5 | Owner text not null,
6 | PublicKeyPEM text not null
7 | );
--------------------------------------------------------------------------------
/db/scripts/11.sql:
--------------------------------------------------------------------------------
1 | drop table Actors;
2 | drop table WebFingerAccts;
3 |
4 | -- DROPPED IN 12: forgot the primary key
5 |
6 | create table Actors (
7 | ID text not null,
8 | PreferredUsername text not null,
9 | Inbox text not null,
10 | DisplayedName text not null,
11 | Summary text not null,
12 |
13 | Domain text not null,
14 | LastCheckedAt text not null default current_timestamp
15 | );
16 |
--------------------------------------------------------------------------------
/db/scripts/12.sql:
--------------------------------------------------------------------------------
1 | -- Sorry for losing data here, my dear adventurous beta user:
2 | drop table if exists Actors;
3 | drop table if exists Following;
4 | drop table if exists Followers;
5 |
6 | -- Adding primary keys to the tables from 9.sql.
7 |
8 | -- Accounts I am following.
9 | create table Following (
10 | -- ActivityPub URL ID.
11 | ActorID text not null primary key,
12 | -- When I asked to subscribe.
13 | SubscribedAt text not null default current_timestamp,
14 | -- 0 for requested, 1 for accepted. When a Reject is received, drop the entry manually.
15 | AcceptedStatus integer not null default 0
16 | );
17 |
18 | -- Accounts that follow me. Rejected accounts don't make it here.
19 | create table Followers (
20 | -- ActivityPub URL ID.
21 | ActorID text not null primary key,
22 | -- When the request was accepted.
23 | SubscribedAt text not null default current_timestamp
24 | );
25 |
26 | -- See 7 for the prev ver.
27 | create table Actors (
28 | ID text not null primary key,
29 | PreferredUsername text not null,
30 | Inbox text not null,
31 | DisplayedName text not null,
32 | Summary text not null,
33 |
34 | Domain text not null,
35 | LastCheckedAt text not null default current_timestamp
36 | );
37 |
38 |
--------------------------------------------------------------------------------
/db/scripts/13.sql:
--------------------------------------------------------------------------------
1 | drop table IncomingPosts;
2 |
3 | -- RemoteBookmarks lists all known bookmarks that were sent from who we follow our way.
4 | -- These bookmarks can and will be deleted at some point.
5 | --
6 | -- We don't have Visibility and DeletedAt fields. If we have the post, it's public
7 | -- enough for our purposes. DeletedAt bears no value, we'll just drop deleted
8 | -- posts.
9 | create table RemoteBookmarks (
10 | -- EXTENDED IN 14: field URL text not null was added.
11 | -- ActivityPub's ID, serves as post's URL.
12 | ID text primary key,
13 | -- Nullable. Distinguishes if this is an original post or not. If not null, it is ID of the original post.
14 | RepostOf text,
15 | ActorID text not null,
16 |
17 | -- If the original post didn't have a title, then it probably wasn't made with Betula in mind. Maybe come up with something yourself. Anyway, it is not null and must be present.
18 | Title text not null check (Title <> ''),
19 | DescriptionHTML text not null,
20 | -- Nullable. Only Betula broadcasts it out after all.
21 | DescriptionMycomarkup text,
22 | -- No default value. Must be present in the activity.
23 | PublishedAt text not null,
24 | -- Null means it was never Updated.
25 | UpdatedAt text,
26 | Activity blob
27 | );
28 |
29 | create table RemoteTags (
30 | Name text not null,
31 | BookmarkID text not null,
32 | unique(Name, BookmarkID)
33 | );
--------------------------------------------------------------------------------
/db/scripts/14.sql:
--------------------------------------------------------------------------------
1 | -- See 13.
2 | -- The default value is not to be used, I just have to provide some default value for not null column.
3 | alter table RemoteBookmarks add column URL text not null default '';
--------------------------------------------------------------------------------
/db/scripts/15.sql:
--------------------------------------------------------------------------------
1 | create table Bookmarks (
2 | ID integer primary key autoincrement,
3 | URL text not null check (URL <> ''),
4 | Title text not null check (Title <> ''),
5 | Description text not null,
6 | Visibility integer check (Visibility = 0 or Visibility = 1 or Visibility = 2), -- private public unlisted
7 | CreationTime text not null default current_timestamp,
8 | DeletionTime text,
9 |
10 | RepostOf text,
11 | OriginalAuthorID text
12 | );
13 |
14 | insert into Bookmarks
15 | (ID, URL, Title, Description, Visibility, CreationTime, DeletionTime, RepostOf, OriginalAuthorID)
16 | select
17 | ID, URL, Title, Description, Visibility, CreationTime, DeletionTime, RepostOf, null
18 | from Posts;
19 |
20 | drop table Posts;
--------------------------------------------------------------------------------
/db/scripts/16.sql:
--------------------------------------------------------------------------------
1 | alter table Sessions
2 | add column UserAgent text
--------------------------------------------------------------------------------
/db/scripts/17.sql:
--------------------------------------------------------------------------------
1 | drop table if exists Artifacts;
2 | drop table if exists Archives;
3 |
4 | -- Artifacts are icons, compressed web pages, etc.
5 | create table Artifacts
6 | (
7 | ID text primary key,
8 | MimeType text not null,
9 | Data blob not null,
10 | IsGzipped integer not null default 0
11 | );
12 |
13 | -- Archives are copies of web pages.
14 | create table Archives
15 | (
16 | ID integer primary key autoincrement,
17 | BookmarkID integer not null,
18 | ArtifactID text not null,
19 | SavedAt text not null default current_timestamp
20 | );
21 |
--------------------------------------------------------------------------------
/db/scripts/7.sql:
--------------------------------------------------------------------------------
1 | -- Artifacts is a storage for binary artifacts from remote resources
2 | -- such as avatars, favicons, whatnot. General purpose.
3 | create table Artifacts
4 | (
5 | ID text primary key,
6 | MimeType text, -- nullable. Just send as is if no idea what is it.
7 | Data blob,
8 | SavedAt text not null default current_timestamp,
9 | LastCheckedAt text -- null = never checked
10 |
11 | -- 18 rewrote Artifacts.
12 | );
13 |
14 | -- THE REST OF THIS FILE WAS DROPPED LATER
15 |
16 | -- DROPPED IN 11: the table was created anew
17 | -- Actors is a storage for all known actors.
18 | create table Actors
19 | (
20 | ID text primary key, -- ActivityPub's URL id
21 | Inbox text not null, -- The spec says it MUST be present, so we'll find it somehow, don't worry.
22 | PreferredUsername text not null,
23 | DisplayedName text not null,
24 | Summary text not null,
25 | IconID text, -- ActivityPub's URL id, nullable
26 | ServerSoftware text, -- "betula", "mastodon", whatever.
27 | foreign key (IconID) references Artifacts (ID)
28 | );
29 | -- END DROPPED
30 |
31 | -- DROPPED IN 9: the table was split into two and simplified
32 | -- Subscriptions lists all known subscriptionship relations between you and others.
33 | create table Subscriptions
34 | (
35 | ID integer primary key, -- Will be used for referencing subscription filters
36 | AuthorID text, -- ActivityPub's URL id. If null, then it means your Betula
37 | SubscriberID text, -- ActivityPub's URL id. If null, then it means your Betula
38 | SubscribedAt text not null default current_timestamp,
39 | Accepted integer not null, -- 0 = not accepted, 1 = accepted
40 | foreign key (AuthorID) references Actors (ID),
41 | foreign key (SubscriberID) references Actors (ID)
42 | );
43 | -- END DROPPED
44 |
45 | -- DROPPED IN 13: extended
46 | -- IncomingPosts lists all known posts that were sent from who we follow our way.
47 | -- These posts can and will be deleted at user's will.
48 | --
49 | -- We don't have Visibility and DeletedAt fields. If we have the post, it's public
50 | -- enough for our purposes. DeletedAt bears no value, we'll just drop deleted
51 | -- posts.
52 | create table IncomingPosts
53 | (
54 | ID text primary key, -- ActivityPub's URL, serves as post's URL.
55 | RepostOf text, -- nullable. Distinguishes if this is an original post or not
56 |
57 | Title text not null,
58 | Description text not null,
59 | CreatedAt text not null
60 | );
61 | -- END DROPPED
--------------------------------------------------------------------------------
/db/scripts/8.sql:
--------------------------------------------------------------------------------
1 | -- DROPPED IN 11: it was causing confusion to me
2 | create table WebFingerAccts (
3 | Acct text primary key, -- In acct:bouncepaw@links.bouncepaw.com, everything after acct:
4 | ActorURL text, -- This is the id that will be used for the actor
5 | Document blob, -- The whole document
6 | LastCheckedAt text not null default current_timestamp
7 | )
8 | -- END DROPPED
--------------------------------------------------------------------------------
/db/scripts/9.sql:
--------------------------------------------------------------------------------
1 | drop table Subscriptions;
2 |
3 | -- DROPPED IN 12: they were migrated to similar tables that have primary keys
4 |
5 | -- Accounts I am following.
6 | create table Following (
7 | -- ActivityPub URL ID.
8 | ActorID text not null,
9 | -- When I asked to subscribe.
10 | SubscribedAt text not null default current_timestamp,
11 | -- 0 for requested, 1 for accepted. When a Reject is received, drop the entry manually.
12 | AcceptedStatus integer not null default 0
13 | );
14 |
15 | -- Accounts that follow me. Rejected accounts don't make it here.
16 | create table Followers (
17 | -- ActivityPub URL ID.
18 | ActorID text not null,
19 | -- When the request was accepted.
20 | SubscribedAt text not null default current_timestamp
21 | );
22 |
--------------------------------------------------------------------------------
/db/scripts/Actual.md:
--------------------------------------------------------------------------------
1 | Last checked: 2024-02-28
2 |
3 | | **Version** | **Description** |
4 | |-------------|-------------------------------------------------------------------------------|
5 | | 6 | tables TagsToPosts, BetulaMeta, Sessions, TagDescriptions, Jobs, KnownReposts |
6 | | 7 | dropped |
7 | | 8 | dropped |
8 | | 9 | dropped |
9 | | 10 | table PublicKeys |
10 | | 11 | dropped |
11 | | 12 | tables Following, Followers, Actors |
12 | | 13 | tables RemoteBookmarks, RemoteTags |
13 | | 14 | changes RemoteBookmarks |
14 | | 15 | table Bookmarks |
15 | | 16 | changes Sessions |
16 | | 17 | table Archives, new Artifacts |
17 |
18 | The code for DB versions 1 to 5 never gets executed.
--------------------------------------------------------------------------------
/db/testing.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | _ "github.com/mattn/go-sqlite3"
5 | )
6 |
7 | /*
8 | This file contains testing things that are used in all tests in this package, and beyond.
9 |
10 | Pay attention to the fish below and do not forget to InitInMemoryDB!
11 | */
12 |
13 | const pufferfish = "🐡"
14 | const tropicfish = "🐠"
15 |
16 | // InitInMemoryDB initializes a database in :memory:. Use it instead of a real db in a file for tests.
17 | func InitInMemoryDB() {
18 | Initialize(":memory:")
19 | const q = `
20 | insert into Bookmarks
21 | (URL, Title, Description, Visibility, CreationTime, DeletionTime)
22 | values
23 | (
24 | 'https://bouncepaw.com',
25 | 'Bouncepaw website',
26 | 'A cute website by Bouncepaw',
27 | 0, '2023-03-17 13:13:13', null
28 | ),
29 | (
30 | 'https://mycorrhiza.wiki',
31 | 'Mycorrhiza Wiki',
32 | 'A wiki engine',
33 | 1, '2023-03-17 13:14:15', null
34 | ),
35 | (
36 | 'http://lesarbr.es',
37 | 'Les Arbres',
38 | 'Legacy mirror of [[1]]',
39 | 1, '2023-03-17 20:20:20', '2023-03-18 12:45:04'
40 | )
41 | `
42 | mustExec(q)
43 | }
44 |
45 | func MoreTestingBookmarks() {
46 | mustExec(`
47 | insert into Bookmarks (URL, Title, Description, Visibility, CreationTime, DeletionTime)
48 | values
49 | ('https://1.bouncepaw', 'Uno', '', 1, '2023-03-19 12:00:00', null),
50 | ('https://2.bouncepaw', 'Dos', '', 1, '2023-03-19 14:14:14', '2023-03-19 14:14:15'),
51 | ('https://3.bouncepaw', 'Tres', '', 1, '2023-03-20 19:19:19', null),
52 | ('https://4.bouncepaw', 'Cuatro', '', 1, '2023-03-20 20:20:20', null);
53 | `)
54 | }
55 |
--------------------------------------------------------------------------------
/fediverse/activities/accept.go:
--------------------------------------------------------------------------------
1 | package activities
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "git.sr.ht/~bouncepaw/betula/settings"
7 | "git.sr.ht/~bouncepaw/betula/stricks"
8 | )
9 |
10 | // NewAccept wraps the acceptedActivity in an Accept activity.
11 | // The @context of the wrapped activity is deleted.
12 | func NewAccept(acceptedActivity Dict) ([]byte, error) {
13 | delete(acceptedActivity, "@context")
14 | return json.Marshal(Dict{
15 | "@context": atContext,
16 | "id": fmt.Sprintf("%s/temp/%s", settings.SiteURL(), stricks.RandomWhatever()),
17 | "type": "Accept",
18 | "actor": betulaActor,
19 | "object": acceptedActivity,
20 | })
21 | }
22 |
23 | type AcceptReport struct {
24 | ActorID string
25 | ObjectID string
26 | Object Dict
27 | }
28 |
29 | func guessAccept(activity Dict) (any, error) {
30 | report := AcceptReport{
31 | ActorID: getIDSomehow(activity, "actor"),
32 | ObjectID: getIDSomehow(activity, "object"),
33 | }
34 | if report.ActorID == "" {
35 | return nil, ErrNoActor
36 | }
37 | if report.ObjectID == "" {
38 | return nil, ErrNoObject
39 | }
40 | if obj, ok := activity["object"]; ok {
41 | switch v := obj.(type) {
42 | case Dict:
43 | report.Object = v
44 | }
45 | }
46 |
47 | return report, nil
48 | }
49 |
--------------------------------------------------------------------------------
/fediverse/activities/activities.go:
--------------------------------------------------------------------------------
1 | // Package activities provides generation of JSON activities and activity data extraction from JSON.
2 | //
3 | // JSON activities are made with New* functions. They all have the same actor. Call GenerateBetulaActor to regenerate the actor.
4 | package activities
5 |
6 | import (
7 | "errors"
8 | "git.sr.ht/~bouncepaw/betula/settings"
9 | "git.sr.ht/~bouncepaw/betula/stricks"
10 | "git.sr.ht/~bouncepaw/betula/types"
11 | "time"
12 | )
13 |
14 | func getIDSomehow(activity Dict, field string) string {
15 | m := activity[field]
16 | switch v := m.(type) {
17 | case string:
18 | if stricks.ValidURL(v) {
19 | return v
20 | }
21 | return ""
22 | }
23 | for k, v := range m.(Dict) {
24 | if k != "id" {
25 | continue
26 | }
27 | switch v := v.(type) {
28 | case string:
29 | return v
30 | default:
31 | return ""
32 | }
33 | }
34 | return ""
35 | }
36 |
37 | func getTime(object Dict, field string) string {
38 | rfc3339 := getString(object, field)
39 | t, err := time.Parse(time.RFC3339, rfc3339)
40 | if err != nil {
41 | return ""
42 | }
43 | return t.Format(types.TimeLayout)
44 | }
45 |
46 | func getString(activity Dict, field string) string {
47 | m := activity[field]
48 | switch v := m.(type) {
49 | case string:
50 | return v
51 | }
52 | return ""
53 | }
54 |
55 | const atContext = "https://www.w3.org/ns/activitystreams"
56 | const publicAudience = "https://www.w3.org/ns/activitystreams#Public"
57 |
58 | type Dict = map[string]any
59 |
60 | var (
61 | ErrNoType = errors.New("activities: type absent or invalid")
62 | ErrNoActor = errors.New("activities: actor absent or invalid")
63 | ErrNoActorUsername = errors.New("activities: actor with absent or invalid username")
64 | ErrUnknownType = errors.New("activities: unknown activity type")
65 | ErrNoId = errors.New("activities: id absent or invalid")
66 | ErrNoObject = errors.New("activities: object absent or invalid")
67 | ErrEmptyField = errors.New("activities: empty field")
68 | ErrNotNote = errors.New("activities: not a Note")
69 | ErrHostMismatch = errors.New("activities: host mismatch")
70 | )
71 |
72 | var betulaActor string
73 |
74 | // GenerateBetulaActor updates what actor to use for outgoing activities.
75 | // It makes no validation.
76 | func GenerateBetulaActor() {
77 | username := settings.AdminUsername()
78 | if username == "" {
79 | username = "betula"
80 | }
81 | betulaActor = settings.SiteURL() + "/@" + username
82 | }
83 |
--------------------------------------------------------------------------------
/fediverse/activities/announce.go:
--------------------------------------------------------------------------------
1 | package activities
2 |
3 | import (
4 | "encoding/json"
5 | "git.sr.ht/~bouncepaw/betula/settings"
6 | "git.sr.ht/~bouncepaw/betula/stricks"
7 | )
8 |
9 | func NewAnnounce(originalURL string, repostURL string) ([]byte, error) {
10 | activity := map[string]any{
11 | "@context": atContext,
12 | "type": "Announce",
13 | "actor": map[string]string{
14 | "id": betulaActor,
15 | "preferredUsername": settings.AdminUsername(),
16 | },
17 | "id": repostURL,
18 | "object": originalURL,
19 | }
20 | return json.Marshal(activity)
21 | }
22 |
23 | type AnnounceReport struct {
24 | ReposterUsername string
25 | RepostPage string // page where the repost is
26 | OriginalPage string // page that was reposted
27 | }
28 |
29 | func mustHaveSuchField[T any](activity Dict, field string, errOnLack error, lambdaOnPresence func(T)) error {
30 | if val, ok := activity[field]; !ok {
31 | return errOnLack
32 | } else {
33 | switch v := val.(type) {
34 | case T:
35 | lambdaOnPresence(v)
36 | return nil
37 | default:
38 | return errOnLack
39 | }
40 | }
41 | }
42 |
43 | func guessAnnounce(activity Dict) (reportMaybe any, err error) {
44 | var (
45 | actorMap Dict
46 | report AnnounceReport
47 | )
48 |
49 | if err := mustHaveSuchField(
50 | activity, "actor", ErrNoActor,
51 | func(v Dict) {
52 | actorMap = v
53 | },
54 | ); err != nil {
55 | return nil, err
56 | }
57 |
58 | if err := mustHaveSuchField(
59 | actorMap, "preferredUsername", ErrNoActorUsername,
60 | func(v string) {
61 | report.ReposterUsername = v
62 | },
63 | ); err != nil {
64 | return nil, err
65 | }
66 |
67 | if err := mustHaveSuchField(
68 | activity, "object", ErrNoObject,
69 | func(v string) {
70 | report.OriginalPage = v
71 | },
72 | ); err != nil {
73 | return nil, err
74 | }
75 |
76 | if err := mustHaveSuchField(
77 | activity, "id", ErrNoId,
78 | func(v string) {
79 | report.RepostPage = v
80 | },
81 | ); err != nil {
82 | return nil, err
83 | }
84 |
85 | if !stricks.ValidURL(report.OriginalPage) {
86 | return nil, ErrNoObject
87 | }
88 |
89 | if !stricks.ValidURL(report.RepostPage) {
90 | return nil, ErrNoId
91 | }
92 |
93 | return report, nil
94 | }
95 |
--------------------------------------------------------------------------------
/fediverse/activities/announce_test.go:
--------------------------------------------------------------------------------
1 | package activities
2 |
3 | import (
4 | "errors"
5 | "reflect"
6 | "testing"
7 | )
8 |
9 | const json1 = `
10 | {
11 | "@context": "https://www.w3.org/ns/activitystreams",
12 | "type": "Announce",
13 | "actor": {
14 | "type": "Person",
15 | "id": "https://links.alice",
16 | "inbox": "https://links.alice/inbox",
17 | "name": "Alice",
18 | "preferredUsername": "alice"
19 | },
20 | "id": "https://links.alice/84",
21 | "object": "https://links.bob/42"
22 | }`
23 |
24 | const jsonNoId = `
25 | {
26 | "@context": "https://www.w3.org/ns/activitystreams",
27 | "type": "Announce",
28 | "actor": {
29 | "type": "Person",
30 | "id": "https://links.alice",
31 | "inbox": "https://links.alice/inbox",
32 | "name": "Alice",
33 | "preferredUsername": "alice"
34 | },
35 | "object": "https://links.bob/42"
36 | }`
37 |
38 | const jsonBadId = `
39 | {
40 | "@context": "https://www.w3.org/ns/activitystreams",
41 | "type": "Announce",
42 | "actor": {
43 | "type": "Person",
44 | "id": "https://links.alice",
45 | "inbox": "https://links.alice/inbox",
46 | "name": "Alice",
47 | "preferredUsername": "alice"
48 | },
49 | "id": {"c'était trop beau": 4},
50 | "object": "https://links.bob/42"
51 | }`
52 |
53 | const jsonNoUsername = `
54 | {
55 | "@context": "https://www.w3.org/ns/activitystreams",
56 | "type": "Announce",
57 | "actor": {
58 | "type": "Person",
59 | "id": "https://links.alice",
60 | "inbox": "https://links.alice/inbox",
61 | "name": "Alice"
62 | },
63 | "id": "https://links.alice/84",
64 | "object": "https://links.bob/42"
65 | }`
66 |
67 | const jsonSallyOffered = `
68 | {
69 | "@context": "https://www.w3.org/ns/activitystreams",
70 | "summary": "Sally offered the Foo object",
71 | "type": "Offer",
72 | "actor": {
73 | "type": "Person",
74 | "id": "http://sally.example.org",
75 | "summary": "Sally"
76 | },
77 | "object": "http://example.org/foo"
78 | }`
79 |
80 | const badJson = `Laika`
81 |
82 | var table = []struct {
83 | json string
84 | err error
85 | report any
86 | }{
87 | {json1, nil, AnnounceReport{
88 | ReposterUsername: "alice",
89 | RepostPage: "https://links.alice/84",
90 | OriginalPage: "https://links.bob/42",
91 | }},
92 | {jsonNoId, ErrNoId, nil},
93 | {jsonBadId, ErrNoId, nil},
94 | {jsonNoUsername, ErrNoActorUsername, nil},
95 | {badJson, errors.New("invalid character 'L' looking for beginning of value"), nil},
96 | {jsonSallyOffered, ErrUnknownType, nil},
97 | // one might want to write many more tests
98 | }
99 |
100 | func TestGuess(t *testing.T) {
101 | for i, test := range table {
102 | report, err := Guess([]byte(test.json))
103 | if test.err != nil && err.Error() != test.err.Error() {
104 | t.Errorf("Error failed. Test %d: %q ≠ %q", i+1, err, test.err)
105 | }
106 | if report == nil && test.report == nil {
107 | continue
108 | }
109 | if reflect.TypeOf(report) != reflect.TypeOf(test.report) {
110 | t.Errorf("Report types mismatch. Test %d: %v ≠ %v", i+1, report, test.report)
111 | }
112 | switch r := test.report.(type) {
113 | case AnnounceReport:
114 | R := report.(AnnounceReport)
115 | if !reflect.DeepEqual(r, R) {
116 | t.Errorf("Report failed. Test %d: %v ≠ %v", i+1, report, test.report)
117 | }
118 | default:
119 | panic("how did this happen")
120 | }
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/fediverse/activities/follow.go:
--------------------------------------------------------------------------------
1 | package activities
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "git.sr.ht/~bouncepaw/betula/settings"
7 | )
8 |
9 | func NewUndoFollowFromUs(objectID string) ([]byte, error) {
10 | activity := Dict{
11 | "@context": atContext,
12 | "id": fmt.Sprintf("%s/unfollow?account=%s", settings.SiteURL(), objectID),
13 | "type": "Undo",
14 | "actor": betulaActor,
15 | "object": Dict{
16 | "id": fmt.Sprintf("%s/follow?account=%s", settings.SiteURL(), objectID),
17 | "type": "Follow",
18 | "actor": betulaActor,
19 | "object": objectID,
20 | },
21 | }
22 | return json.Marshal(activity)
23 | }
24 |
25 | func NewFollowFromUs(objectID string) ([]byte, error) {
26 | activity := Dict{
27 | "@context": atContext,
28 | "id": fmt.Sprintf("%s/follow?account=%s", settings.SiteURL(), objectID),
29 | "type": "Follow",
30 | "actor": betulaActor,
31 | "object": objectID,
32 | }
33 | return json.Marshal(activity)
34 | }
35 |
36 | type FollowReport struct {
37 | ActorID string
38 | ObjectID string
39 | OriginalActivity Dict
40 | }
41 |
42 | func guessFollow(activity Dict) (any, error) {
43 | report := FollowReport{
44 | ActorID: getIDSomehow(activity, "actor"),
45 | ObjectID: getIDSomehow(activity, "object"),
46 | OriginalActivity: activity,
47 | }
48 | if report.ActorID == "" {
49 | return nil, ErrNoActor
50 | }
51 | if report.ObjectID == "" {
52 | return nil, ErrNoObject
53 | }
54 | return report, nil
55 | }
56 |
--------------------------------------------------------------------------------
/fediverse/activities/guess.go:
--------------------------------------------------------------------------------
1 | package activities
2 |
3 | import (
4 | "encoding/json"
5 | "log"
6 | )
7 |
8 | var guesserMap = map[string]func(Dict) (any, error){
9 | "Announce": guessAnnounce,
10 | "Undo": guessUndo,
11 | "Follow": guessFollow,
12 | "Accept": guessAccept,
13 | "Reject": guessReject,
14 | "Create": guessCreateNote,
15 | "Update": guessUpdateNote,
16 | "Delete": guessDeleteNote,
17 | }
18 |
19 | func Guess(raw []byte) (report any, err error) {
20 | var (
21 | activity = Dict{
22 | "original activity": raw,
23 | }
24 | val any
25 | ok bool
26 | )
27 | if err = json.Unmarshal(raw, &activity); err != nil {
28 | return nil, err
29 | }
30 |
31 | if val, ok = activity["type"]; !ok {
32 | return nil, ErrNoType
33 | }
34 | switch v := val.(type) {
35 | case string:
36 | // Special case
37 | if v == "Delete" && getString(activity, "actor") == getString(activity, "object") {
38 | // Waiting for https://github.com/mastodon/mastodon/pull/22273 to get rid of this branch
39 | log.Println("Somebody got deleted, scroll further.")
40 | return nil, nil
41 | }
42 |
43 | f, ok := guesserMap[v]
44 | if !ok {
45 | log.Printf("Ignoring unknown kind of activity: %s\n", raw)
46 | return nil, ErrUnknownType
47 | }
48 |
49 | log.Printf("Handling activity: %s\n", raw)
50 | return f(activity)
51 | default:
52 | return nil, ErrNoType
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/fediverse/activities/note_test.go:
--------------------------------------------------------------------------------
1 | package activities
2 |
3 | import (
4 | "embed"
5 | "io"
6 | "testing"
7 | )
8 |
9 | //go:embed testdata/*
10 | var fs embed.FS
11 |
12 | func TestGuessCreateNote(t *testing.T) {
13 | f, err := fs.Open("testdata/Create{Note} 1.json")
14 | if err != nil {
15 | panic(err)
16 | }
17 |
18 | raw, err := io.ReadAll(f)
19 | if err != nil {
20 | panic(err)
21 | }
22 |
23 | report, err := Guess(raw)
24 | if err != nil {
25 | t.Error(err)
26 | return
27 | }
28 | r, ok := report.(CreateNoteReport)
29 | if !ok {
30 | t.Error("wrong type")
31 | }
32 |
33 | if len(r.Bookmark.Tags) != 1 {
34 | t.Error("tag len mismatch")
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/fediverse/activities/reject.go:
--------------------------------------------------------------------------------
1 | package activities
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "git.sr.ht/~bouncepaw/betula/settings"
7 | "git.sr.ht/~bouncepaw/betula/stricks"
8 | )
9 |
10 | func NewReject(rejectedActivity Dict) ([]byte, error) {
11 | delete(rejectedActivity, "@context")
12 | activity := Dict{
13 | "@context": atContext,
14 | "id": fmt.Sprintf("%s/temp/%s", settings.SiteURL(), stricks.RandomWhatever()),
15 | "type": "Reject",
16 | "actor": betulaActor,
17 | "object": rejectedActivity,
18 | }
19 | return json.Marshal(activity)
20 | }
21 |
22 | type RejectReport struct {
23 | ActorID string
24 | ObjectID string
25 | Object Dict
26 | }
27 |
28 | func guessReject(activity Dict) (any, error) {
29 | report := RejectReport{
30 | ActorID: getIDSomehow(activity, "actor"),
31 | ObjectID: getIDSomehow(activity, "object"),
32 | }
33 | if report.ActorID == "" {
34 | return nil, ErrNoActor
35 | }
36 | if report.ObjectID == "" {
37 | return nil, ErrNoActor
38 | }
39 | if obj, ok := activity["object"]; ok {
40 | switch v := obj.(type) {
41 | case Dict:
42 | report.Object = v
43 | }
44 | }
45 |
46 | return report, nil
47 | }
48 |
--------------------------------------------------------------------------------
/fediverse/activities/testdata/Create{Note} 1.json:
--------------------------------------------------------------------------------
1 | {
2 | "@context": [
3 | "https://www.w3.org/ns/activitystreams",
4 | {
5 | "Hashtag": "https://www.w3.org/ns/activitystreams#Hashtag"
6 | }
7 | ],
8 | "actor": "https://links.bouncepaw.com/@bouncepaw",
9 | "id": "https://links.bouncepaw.com/1083?create",
10 | "object": {
11 | "actor": "https://links.bouncepaw.com/@bouncepaw",
12 | "attributedTo": "https://links.bouncepaw.com/@bouncepaw",
13 | "content": "\u003ch3\u003e\u003ca href=\"https://www.datagubbe.se/adosmyst/\"'\u003e 403 Forbidden\n\u003c/a\u003e\u003c/h3\u003e\u003carticle class=\"mycomarkup-doc\"\u003e\u003cp\u003eCute.\n\u003c/p\u003e\u003c/article\u003e\u003cp\u003e\u003ca href=\"https://links.bouncepaw.com/tag/retrocomputing\" class=\"mention hashtag\" rel=\"tag\"\u003e#\u003cspan\u003eretrocomputing\u003c/span\u003e\u003c/a\u003e\u003c/p\u003e",
14 | "id": "https://links.bouncepaw.com/1083",
15 | "source": {
16 | "content": "Cute.",
17 | "mediaType": "text/mycomarkup"
18 | },
19 | "name": " 403 Forbidden\n",
20 | "attachment": [
21 | {
22 | "type": "Link",
23 | "href": "https://www.datagubbe.se/adosmyst/"
24 | }
25 | ],
26 | "published": "2024-01-31T19:47:02Z",
27 | "tag": [
28 | {
29 | "href": "https://links.bouncepaw.com/tag/retrocomputing",
30 | "name": "#retrocomputing",
31 | "type": "Hashtag"
32 | }
33 | ],
34 | "to": [
35 | "https://www.w3.org/ns/activitystreams#Public",
36 | "https://links.bouncepaw.com/followers"
37 | ],
38 | "type": "Note"
39 | },
40 | "type": "Create"
41 | }
--------------------------------------------------------------------------------
/fediverse/activities/testdata/Update{Note} 1.json:
--------------------------------------------------------------------------------
1 | {
2 | "@context": [
3 | "https://www.w3.org/ns/activitystreams",
4 | {
5 | "Hashtag": "https://www.w3.org/ns/activitystreams#Hashtag"
6 | }
7 | ],
8 | "actor": "https://links.bouncepaw.com/@bouncepaw",
9 | "id": "https://links.bouncepaw.com/1083?update",
10 | "object": {
11 | "actor": "https://links.bouncepaw.com/@bouncepaw",
12 | "attributedTo": "https://links.bouncepaw.com/@bouncepaw",
13 | "content": "\u003ch3\u003e\u003ca href=\"https://www.datagubbe.se/adosmyst/\"'\u003eWell-known Secrets of AmigaDOS\u003c/a\u003e\u003c/h3\u003e\u003carticle class=\"mycomarkup-doc\"\u003e\u003cp\u003eCute.\n\u003c/p\u003e\u003c/article\u003e\u003cp\u003e\u003ca href=\"https://links.bouncepaw.com/tag/retrocomputing\" class=\"mention hashtag\" rel=\"tag\"\u003e#\u003cspan\u003eretrocomputing\u003c/span\u003e\u003c/a\u003e\u003c/p\u003e",
14 | "id": "https://links.bouncepaw.com/1083",
15 | "source": {
16 | "content": "Cute.",
17 | "mediaType": "text/mycomarkup"
18 | },
19 | "name": "Well-known Secrets of AmigaDOS",
20 | "attachment": [
21 | {
22 | "type": "Link",
23 | "href": "https://www.datagubbe.se/adosmyst/"
24 | }
25 | ],
26 | "published": "2024-01-31T19:47:13Z",
27 | "tag": [
28 | {
29 | "href": "https://links.bouncepaw.com/tag/retrocomputing",
30 | "name": "#retrocomputing",
31 | "type": "Hashtag"
32 | }
33 | ],
34 | "to": [
35 | "https://www.w3.org/ns/activitystreams#Public",
36 | "https://links.bouncepaw.com/followers"
37 | ],
38 | "type": "Note",
39 | "updated": "2024-01-31T19:47:13Z"
40 | },
41 | "type": "Update"
42 | }
--------------------------------------------------------------------------------
/fediverse/activities/undo.go:
--------------------------------------------------------------------------------
1 | package activities
2 |
3 | import (
4 | "encoding/json"
5 | "git.sr.ht/~bouncepaw/betula/settings"
6 | )
7 |
8 | type UndoAnnounceReport struct {
9 | AnnounceReport
10 | }
11 |
12 | type UndoFollowReport struct {
13 | FollowReport
14 | }
15 |
16 | func newUndo(objectId string, object Dict) ([]byte, error) {
17 | object["id"] = objectId
18 | return json.Marshal(Dict{
19 | "@context": atContext,
20 | "type": "Undo",
21 | "actor": betulaActor,
22 | "id": objectId + "?undo",
23 | "object": object,
24 | })
25 | }
26 |
27 | func NewUndoAnnounce(repostURL string, originalPostURL string) ([]byte, error) {
28 | return newUndo(
29 | repostURL,
30 | Dict{
31 | "type": "Announce",
32 | "actor": settings.SiteURL(),
33 | "object": originalPostURL,
34 | })
35 | }
36 |
37 | func guessUndo(activity Dict) (reportMaybe any, err error) {
38 | var (
39 | report UndoAnnounceReport
40 | objectMap Dict
41 | )
42 |
43 | if err := mustHaveSuchField(
44 | activity, "object", ErrNoObject,
45 | func(v map[string]any) {
46 | objectMap = v
47 | },
48 | ); err != nil {
49 | return nil, err
50 | }
51 |
52 | switch objectMap["type"] {
53 | case "Announce":
54 | switch repost := objectMap["id"].(type) {
55 | case string:
56 | report.RepostPage = repost
57 | }
58 | switch original := objectMap["object"].(type) {
59 | case string:
60 | report.OriginalPage = original
61 | }
62 | switch actor := objectMap["actor"].(type) {
63 | case Dict:
64 | switch username := actor["preferredUsername"].(type) {
65 | case string:
66 | report.ReposterUsername = username
67 | }
68 | }
69 | return report, nil
70 | case "Follow":
71 | if objectMap == nil {
72 | return nil, ErrNoObject
73 | }
74 | followReport, err := guessFollow(objectMap)
75 | if err != nil {
76 | return nil, err
77 | }
78 | return UndoFollowReport{followReport.(FollowReport)}, nil
79 | default:
80 | return nil, ErrUnknownType
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/fediverse/activities/undo_test.go:
--------------------------------------------------------------------------------
1 | package activities
2 |
3 | import "testing"
4 |
5 | // This one was handled wrong at some point. Making a test here to fix it.
6 | var undoFollowJSON = []byte(`
7 | {
8 | "@context": "https://www.w3.org/ns/activitystreams",
9 | "actor": "https://bob.bouncepaw.com/@bob",
10 | "id": "https://bob.bouncepaw.com/unfollow?account=https://alice.bouncepaw.com/@alice",
11 | "object": {
12 | "actor": "https://bob.bouncepaw.com/@bob",
13 | "id": "https://bob.bouncepaw.com/follow?account=https://alice.bouncepaw.com/@alice",
14 | "object": "https://alice.bouncepaw.com/@alice",
15 | "type": "Follow"
16 | },
17 | "type": "Undo"
18 | }`)
19 |
20 | func TestGuessUndoFollow(t *testing.T) {
21 | report, err := Guess(undoFollowJSON)
22 | if err != nil {
23 | t.Error(err)
24 | t.Logf("%q", report)
25 | return
26 | }
27 | undoFollowReport, ok := report.(UndoFollowReport)
28 | if !ok {
29 | t.Error("wrong type")
30 | t.Logf("%q", report)
31 | return
32 | }
33 | // and just a little check
34 | if undoFollowReport.ActorID != "https://bob.bouncepaw.com/@bob" {
35 | t.Error("it's all messed up")
36 | t.Logf("%q", report)
37 | return
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/fediverse/actor.go:
--------------------------------------------------------------------------------
1 | package fediverse
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "git.sr.ht/~bouncepaw/betula/db"
8 | "git.sr.ht/~bouncepaw/betula/fediverse/signing"
9 | "git.sr.ht/~bouncepaw/betula/stricks"
10 | "git.sr.ht/~bouncepaw/betula/types"
11 | "io"
12 | "log"
13 | "net/http"
14 | "strings"
15 | )
16 |
17 | // RequestActorByNickname returns actor by string like @bouncepaw@links.bouncepaw.com or bouncepaw@links.bouncepaw.com. The returned value might be from the cache and perhaps stale.
18 | func RequestActorByNickname(nickname string) (*types.Actor, error) {
19 | user, host, ok := strings.Cut(strings.TrimPrefix(nickname, "@"), "@")
20 | if !ok {
21 | return nil, fmt.Errorf("bad username: %s", nickname)
22 | }
23 |
24 | // get cached if possible
25 | a, found := db.ActorByAcct(user, host)
26 | if found {
27 | return a, nil
28 | }
29 |
30 | // find id
31 | id, err := requestIdByWebFingerAcct(user, host)
32 | if err == nil && id == "" {
33 | return nil, fmt.Errorf("user not found 404: %s", nickname)
34 | }
35 | if err != nil {
36 | return nil, err
37 | }
38 |
39 | // make network request
40 | actor, err := dereferenceActorID(id)
41 | if err != nil {
42 | return nil, fmt.Errorf("while fetching actor %s: %w", id, err)
43 | }
44 |
45 | return actor, nil
46 | }
47 |
48 | // RequestActorByID fetches the actor activity on the specified address. The returned value might be from the cache and perhaps stale.
49 | func RequestActorByID(actorID string) (*types.Actor, error) {
50 | // get cached if possible
51 | a, found := db.ActorByID(actorID)
52 | if found {
53 | return a, nil
54 | }
55 |
56 | // make network request
57 | actor, err := dereferenceActorID(actorID)
58 | if err != nil {
59 | return nil, fmt.Errorf("while fetching actor %s: %w", actorID, err)
60 | }
61 |
62 | return actor, nil
63 | }
64 |
65 | func dereferenceActorID(actorID string) (*types.Actor, error) {
66 | req, err := http.NewRequest("GET", actorID, nil)
67 | if err != nil {
68 | return nil, fmt.Errorf("requesting actor: %w", err)
69 | }
70 | req.Header.Set("Accept", types.ActivityType)
71 | signing.SignRequest(req, nil)
72 |
73 | resp, err := client.Do(req)
74 | if err != nil {
75 | return nil, fmt.Errorf("requesting actor: %w", err)
76 | }
77 |
78 | if resp.StatusCode != http.StatusOK {
79 | return nil, fmt.Errorf("requesting actor: status not 200, id est %d", resp.StatusCode)
80 | }
81 |
82 | data, err := io.ReadAll(resp.Body)
83 | if err != nil {
84 | return nil, fmt.Errorf("requesting actor: %w", err)
85 | }
86 |
87 | var a types.Actor
88 | if err = json.Unmarshal(data, &a); err != nil {
89 | return nil, fmt.Errorf("requesting actor: %w", err)
90 | }
91 |
92 | a.Domain = stricks.ParseValidURL(actorID).Host
93 | if !a.Valid() {
94 | fmt.Println(a)
95 | return nil, errors.New("actor invalid")
96 | }
97 | if a.DisplayedName == "" {
98 | a.DisplayedName = a.PreferredUsername
99 | }
100 | db.StoreValidActor(a)
101 | return &a, nil
102 | }
103 |
104 | func RequestActorInboxByID(actorID string) string {
105 | actor, err := RequestActorByID(actorID)
106 | if err != nil {
107 | log.Printf("When requesting actor %s inbox: %s\n", actorID, err)
108 | return ""
109 | }
110 | return actor.Inbox
111 | }
112 |
--------------------------------------------------------------------------------
/fediverse/bookmark.go:
--------------------------------------------------------------------------------
1 | package fediverse
2 |
3 | import (
4 | "database/sql"
5 | "encoding/json"
6 | "errors"
7 | "git.sr.ht/~bouncepaw/betula/readpage"
8 | "io"
9 | "log"
10 | "net/http"
11 |
12 | "git.sr.ht/~bouncepaw/betula/fediverse/activities"
13 | "git.sr.ht/~bouncepaw/betula/settings"
14 | "git.sr.ht/~bouncepaw/betula/types"
15 | )
16 |
17 | var (
18 | ErrNotBookmark = errors.New("fediverse: not a bookmark")
19 | )
20 |
21 | func fetchFedi(uri string) (*types.Bookmark, error) {
22 | req, err := http.NewRequest("GET", uri, nil)
23 | if err != nil {
24 | return nil, err
25 | }
26 | req.Header.Set("User-Agent", settings.UserAgent())
27 | req.Header.Set("Accept", types.OtherActivityType)
28 | resp, err := client.Do(req)
29 | if err != nil {
30 | return nil, err
31 | }
32 |
33 | var object activities.Dict
34 | if err := json.NewDecoder(io.LimitReader(resp.Body, 128_000)).Decode(&object); err != nil {
35 | return nil, err
36 | }
37 |
38 | bookmark, err := activities.RemoteBookmarkFromDict(object)
39 | if err != nil {
40 | return nil, err
41 | }
42 | log.Printf("tags %q\n%q\n", bookmark.Tags, object)
43 |
44 | return &types.Bookmark{
45 | Tags: bookmark.Tags,
46 | URL: bookmark.URL,
47 | Title: bookmark.Title,
48 | Description: bookmark.DescriptionMycomarkup.String,
49 | Visibility: types.Public,
50 | RepostOf: &uri,
51 | OriginalAuthor: sql.NullString{
52 | String: bookmark.ActorID,
53 | Valid: true,
54 | },
55 | }, nil
56 | }
57 |
58 | // FetchBookmarkAsRepost fetches a bookmark on the given address somehow. First, it tries to get a Note ActivityPub object formatted with Betula rules. If it fails to do so, it resorts to the readpage method.
59 | func FetchBookmarkAsRepost(uri string) (*types.Bookmark, error) {
60 | log.Printf("Fetching remote bookmark from %s\n", uri)
61 | bookmark, err := fetchFedi(uri)
62 | if err != nil {
63 | log.Printf("Tried to fetch a remote bookmark from %s, failed with: %s. Falling back to microformats\n", uri, err)
64 | // no return
65 | } else {
66 | log.Printf("Fetched a remote bookmark from %s\n", uri)
67 | return bookmark, nil
68 | }
69 |
70 | foundData, err := readpage.FindDataForMyRepost(uri)
71 | if err != nil {
72 | return nil, err
73 | } else if foundData.IsHFeed || foundData.BookmarkOf == "" || foundData.PostName == "" {
74 | return nil, ErrNotBookmark
75 | }
76 |
77 | log.Printf("Fetched a remote bookmark from %s with readpage\n", uri)
78 | return &types.Bookmark{
79 | Tags: types.TagsFromStringSlice(foundData.Tags),
80 | URL: foundData.BookmarkOf,
81 | Title: foundData.PostName,
82 | Description: foundData.Mycomarkup,
83 | Visibility: types.Public,
84 | RepostOf: &uri, // TODO: transitive reposts are a thing...
85 | OriginalAuthor: sql.NullString{}, // actors are found only in activities
86 | }, nil
87 | }
88 |
--------------------------------------------------------------------------------
/fediverse/fedisearch/Federated Search.md:
--------------------------------------------------------------------------------
1 | # Betula's Approach to Federated Search
2 | There is already an ongoing federated search initiative present, called [Fediverse Discovery Providers](https://www.fediscovery.org), or Fediscovery for short. It's not ready yet, no specs are found, and it's probably going to be very general. I do not particularly want to wait for them, hence I'm rolling out my own ad-hoc protocol suited for federated search of bookmarks.
3 |
4 | I tried to design it universal enough for all fellow Fediverse bookmarking services, but of course it's going to be very biased. After all, I am the one who develops Betula, so of course I take it into consideration first.
5 |
6 | ## Search results
7 | Alice wants to ask Bob for search results. She makes a POST request to `https://BOB/.well-known/betula-federated-search` similar to the following JSON signed with an HTTP signature.
8 |
9 | ```json
10 | {
11 | "version": "v1",
12 | "query": "#solarpunk #software",
13 | "limit": 6,
14 | "offset": 0,
15 | "from": "https://ALICE/@alice",
16 | "to": "https://BOB/@bob"
17 | }
18 | ```
19 |
20 | The cursor might be `null`. Reverse-chronological order is assumed.
21 |
22 | If the signature is fine, the status is 200 and Bob returns an object like this:
23 |
24 | ```json
25 | {
26 | "moreAvailable": 0,
27 | "bookmarks": [
28 | {
29 | "@context": [
30 | "https://www.w3.org/ns/activitystreams",
31 | {
32 | "Hashtag": "https://www.w3.org/ns/activitystreams#Hashtag"
33 | }
34 | ],
35 | "actor": "https://BOB/@bob",
36 | "attributedTo": "https://BOB/@bob",
37 | "content": "\u003ch3\u003e\u003ca href=\"https://www.datagubbe.se/adosmyst/\"'\u003e 403 Forbidden\n\u003c/a\u003e\u003c/h3\u003e\u003carticle class=\"mycomarkup-doc\"\u003e\u003cp\u003eCute.\n\u003c/p\u003e\u003c/article\u003e\u003cp\u003e\u003ca href=\"https://BOB/tag/retrocomputing\" class=\"mention hashtag\" rel=\"tag\"\u003e#\u003cspan\u003eretrocomputing\u003c/span\u003e\u003c/a\u003e\u003c/p\u003e",
38 | "id": "https://BOB/1083",
39 | "source": {
40 | "content": "Cute.",
41 | "mediaType": "text/mycomarkup"
42 | },
43 | "name": " 403 Forbidden\n",
44 | "attachment": [
45 | {
46 | "type": "Link",
47 | "href": "https://www.datagubbe.se/adosmyst/"
48 | }
49 | ],
50 | "published": "2024-01-31T19:47:02Z",
51 | "tag": [
52 | {
53 | "href": "https://BOB/tag/retrocomputing",
54 | "name": "#retrocomputing",
55 | "type": "Hashtag"
56 | }
57 | ],
58 | "to": [
59 | "https://www.w3.org/ns/activitystreams#Public",
60 | "https://BOB/followers"
61 | ],
62 | "type": "Note"
63 | }
64 | ]
65 | }
66 | ```
67 |
68 | The `moreAvailable` field tells how many more bookmarks can be requested by adjusting the query. The `items` is a list of `Note` objects. They are the same as if received over regular ActivityPub broadcasting, except there is no wrapping `Create` or `Update` activity. Alice might save these bookmarks to her database.
69 |
70 | Possible errors are:
71 |
72 | * `400 Bad Request`: couldn't parse the request or something.
73 | * `401 Unauthorized`: something is wrong with the HTTP signature
74 | * `403 Forbidden`: Bob doesn't want to share bookmarks with Alice this way (not mutuals, for example)
75 | * `404 Not Found`: Bob doesn't have federated search enabled or at all
76 | * `500 Internal Server Error`: something went wrong on Bob's side
77 |
78 | I should have used an OpenAPI spec, haven't I?
79 |
80 | ## Considered alternatives
81 | * Straight up parsing everything. Rude!
82 | * Asking for results with a custom activity. That'd be harder and too formal.
--------------------------------------------------------------------------------
/fediverse/fedisearch/api.go:
--------------------------------------------------------------------------------
1 | package fedisearch
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "git.sr.ht/~bouncepaw/betula/db"
7 | "git.sr.ht/~bouncepaw/betula/fediverse"
8 | "git.sr.ht/~bouncepaw/betula/types"
9 | )
10 |
11 | var (
12 | ErrUnsupportedVersion = errors.New("unsupported version")
13 | ErrWrongTo = errors.New("field to does not match")
14 | ErrNotMutual = errors.New("not mutual")
15 | )
16 |
17 | func ParseAPIRequest(bytes []byte) (*Request, error) {
18 | var req Request
19 | var err = json.Unmarshal(bytes, &req)
20 | if err != nil {
21 | return nil, err
22 | }
23 |
24 | switch {
25 | case req.Version != "v1":
26 | return nil, ErrUnsupportedVersion
27 | case req.To != fediverse.OurID():
28 | return nil, ErrWrongTo
29 | case db.SubscriptionStatus(req.From) != types.SubscriptionMutual:
30 | return nil, ErrNotMutual
31 | }
32 |
33 | return &req, nil
34 | }
35 |
--------------------------------------------------------------------------------
/fediverse/fedisearch/fedisearch_test.go:
--------------------------------------------------------------------------------
1 | package fedisearch
2 |
3 | import (
4 | "math/rand"
5 | "reflect"
6 | "slices"
7 | "strings"
8 | "testing"
9 | )
10 |
11 | func TestState_RequestsToMake(t *testing.T) {
12 | // NOTE(bouncepaw): this very special seed was used to pick
13 | // test values very precisely. I pray nothing ever breaks,
14 | // because changing the test values would be a chore.
15 | rand.Seed(0b0111001101100001011011000110000101101101)
16 |
17 | type fields struct {
18 | query string
19 | seen map[string]int
20 | expected map[string]int
21 | unseen []string
22 | ourID string
23 | }
24 | tests := []struct {
25 | name string
26 | fields fields
27 | want []Request
28 | }{
29 | {
30 | "A/First page",
31 | fields{
32 | query: "A",
33 | seen: nil,
34 | expected: nil,
35 | unseen: []string{"Alice", "Bob", "Charlie", "David"},
36 | ourID: "Betulizer",
37 | },
38 | []Request{
39 | {"v1", "A", 15, 0, "Betulizer", "Alice"},
40 | {"v1", "A", 15, 0, "Betulizer", "Bob"},
41 | {"v1", "A", 15, 0, "Betulizer", "Charlie"},
42 | {"v1", "A", 20, 0, "Betulizer", "David"},
43 | },
44 | },
45 | {
46 | "A/Second page",
47 | fields{
48 | query: "A",
49 | seen: map[string]int{
50 | "Alice": 15, "Bob": 15, "Charlie": 15, "David": 20,
51 | },
52 | expected: map[string]int{
53 | "Alice": 77, "Bob": 4, "David": 17, // Charlie depleted
54 | },
55 | unseen: nil,
56 | ourID: "Betulizer",
57 | },
58 | []Request{ // Returns 66 bookmarks here
59 | {"v1", "A", 45, 15, "Betulizer", "Alice"},
60 | {"v1", "A", 4, 15, "Betulizer", "Bob"},
61 | {"v1", "A", 17, 20, "Betulizer", "David"},
62 | },
63 | },
64 | {
65 | "A/Third page",
66 | fields{
67 | query: "A",
68 | seen: map[string]int{
69 | "Alice": 15 + 45, "Bob": 15 + 4, "Charlie": 15, "David": 20 + 17,
70 | },
71 | expected: map[string]int{
72 | "Alice": 17, // Only Alice stands.
73 | },
74 | unseen: nil,
75 | ourID: "Betulizer",
76 | },
77 | []Request{
78 | {"v1", "A", 17, 15 + 45, "Betulizer", "Alice"},
79 | },
80 | },
81 | }
82 | for _, tt := range tests {
83 | t.Run(tt.name, func(t *testing.T) {
84 | s := &State{
85 | Query: tt.fields.query,
86 | Seen: tt.fields.seen,
87 | Expected: tt.fields.expected,
88 | Unseen: tt.fields.unseen,
89 | ourID: tt.fields.ourID,
90 | }
91 | reqs := slices.SortedFunc(
92 | slices.Values(s.RequestsToMake()),
93 | func(e Request, e2 Request) int {
94 | return strings.Compare(e.To, e2.To)
95 | },
96 | )
97 | if got := reqs; !reflect.DeepEqual(got, tt.want) {
98 | t.Errorf("\ngot %v,\nwant %v", got, tt.want)
99 | }
100 | })
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/fediverse/fediverse.go:
--------------------------------------------------------------------------------
1 | // Package fediverse has some of the Fediverse-related functions.
2 | package fediverse
3 |
4 | import (
5 | "bytes"
6 | "git.sr.ht/~bouncepaw/betula/fediverse/signing"
7 | "io"
8 | "log"
9 | "log/slog"
10 | "net/http"
11 | "time"
12 |
13 | "git.sr.ht/~bouncepaw/betula/db"
14 | "git.sr.ht/~bouncepaw/betula/myco"
15 | "git.sr.ht/~bouncepaw/betula/settings"
16 | "git.sr.ht/~bouncepaw/betula/types"
17 | )
18 |
19 | var client = http.Client{
20 | Timeout: 2 * time.Second,
21 | }
22 |
23 | func PostSignedDocumentToAddress(doc []byte, contentType string, accept string, addr string) ([]byte, int, error) {
24 | rq, err := http.NewRequest(http.MethodPost, addr, bytes.NewReader(doc))
25 | if err != nil {
26 | slog.Error("Failed to prepare signed document request",
27 | "err", err, "addr", addr)
28 | return nil, 0, err
29 | }
30 |
31 | rq.Header.Set("User-Agent", settings.UserAgent())
32 | rq.Header.Set("Content-Type", contentType)
33 | rq.Header.Set("Accept", accept)
34 | signing.SignRequest(rq, doc)
35 |
36 | resp, err := client.Do(rq)
37 | if err != nil {
38 | slog.Error("Failed to send signed document request",
39 | "err", err, "addr", addr)
40 | return nil, 0, err
41 | }
42 |
43 | var (
44 | bodyReader = io.LimitReader(resp.Body, 1024*1024*10)
45 | body []byte
46 | )
47 | body, err = io.ReadAll(bodyReader)
48 | if err != nil {
49 | slog.Error("Failed to read body", "err", err)
50 | return nil, 0, err
51 | }
52 |
53 | if resp.StatusCode != http.StatusOK {
54 | slog.Warn("Non-OK status code returned",
55 | "err", err, "addr", addr, "status", resp.StatusCode)
56 | }
57 |
58 | return body, resp.StatusCode, nil
59 | }
60 |
61 | func OurID() string {
62 | return settings.SiteURL() + "/@" + settings.AdminUsername()
63 | }
64 |
65 | func RenderRemoteBookmarks(raws []types.RemoteBookmark) (renders []types.RenderedRemoteBookmark) {
66 | // Gather actor info to prevent duplicate fetches from db
67 | actors := map[string]*types.Actor{}
68 | for _, raw := range raws {
69 | actors[raw.ActorID] = nil
70 | }
71 | for actorID, _ := range actors {
72 | actor, _ := db.ActorByID(actorID)
73 | actors[actorID] = actor // might be nil? I doubt it
74 | }
75 |
76 | // Rendering
77 | for _, raw := range raws {
78 | actor, ok := actors[raw.ActorID]
79 | if !ok {
80 | log.Printf("When rendering remote bookmarks: actor %s not found\n", raw.ActorID)
81 | continue // whatever
82 | }
83 |
84 | render := types.RenderedRemoteBookmark{
85 | ID: raw.ID,
86 | AuthorAcct: actor.Acct(),
87 | AuthorDisplayedName: actor.PreferredUsername,
88 | RepostOf: raw.RepostOf,
89 | Title: raw.Title,
90 | URL: raw.URL,
91 | Tags: raw.Tags,
92 | }
93 |
94 | t, err := time.Parse(types.TimeLayout, raw.PublishedAt)
95 | if err != nil {
96 | log.Printf("When rendering remote bookmarks: %s\n", err)
97 | continue // whatever
98 | }
99 | render.PublishedAt = t
100 |
101 | if raw.DescriptionMycomarkup.Valid {
102 | render.Description = myco.MarkupToHTML(raw.DescriptionMycomarkup.String)
103 | } else {
104 | render.Description = raw.DescriptionHTML
105 | }
106 |
107 | renders = append(renders, render)
108 | }
109 |
110 | return renders
111 | }
112 |
--------------------------------------------------------------------------------
/fediverse/signing/signing.go:
--------------------------------------------------------------------------------
1 | // Package signing manages HTTP signatures and managing a pair of private and public keys. This package is a wrapper around Ted of the Honk's httpsig package.
2 | package signing
3 |
4 | import (
5 | "crypto/rand"
6 | "crypto/rsa"
7 | "database/sql"
8 | "log"
9 | "net/http"
10 |
11 | "git.sr.ht/~bouncepaw/betula/db"
12 | "git.sr.ht/~bouncepaw/betula/settings"
13 | "humungus.tedunangst.com/r/webs/httpsig"
14 | )
15 |
16 | // SignRequest signs the request.
17 | func SignRequest(rq *http.Request, content []byte) {
18 | keyId := settings.SiteURL() + "/@" + settings.AdminUsername() + "#main-key"
19 | httpsig.SignRequest(keyId, privateKey, rq, content)
20 | }
21 |
22 | var (
23 | privateKey httpsig.PrivateKey
24 | publicKey httpsig.PublicKey
25 | publicKeyPEM string
26 | )
27 |
28 | func PublicKey() string {
29 | return publicKeyPEM
30 | }
31 |
32 | func setKeys(privateKeyPEM string) {
33 | var err error
34 | privateKey, publicKey, err = httpsig.DecodeKey(privateKeyPEM)
35 | if err != nil {
36 | log.Fatalf("When decoding private key PEM: %s\n", err)
37 | }
38 |
39 | publicKeyPEM, err = httpsig.EncodeKey(publicKey.Key)
40 | if err != nil {
41 | log.Fatalf("When encoding public key PEM: %s\n", err)
42 | }
43 | }
44 |
45 | // EnsureKeysFromDatabase reads the keys from the database and remembers them. If they are not found, it comes up with new ones and saves them. This function might crash the application.
46 | func EnsureKeysFromDatabase() {
47 | var pem string
48 | privKeyPEMMaybe := db.MetaEntry[sql.NullString](db.BetulaMetaPrivateKey)
49 | if !privKeyPEMMaybe.Valid || privKeyPEMMaybe.String == "" {
50 | log.Println("Generating a new pair of RSA keys")
51 | priv, err := rsa.GenerateKey(rand.Reader, 2048)
52 | if err != nil {
53 | log.Fatalf("When generating new keys: %s\n", err)
54 | }
55 |
56 | pem, err = httpsig.EncodeKey(priv)
57 | if err != nil {
58 | log.Fatalf("When generating private key PEM: %s\n", err)
59 | }
60 |
61 | db.SetMetaEntry(db.BetulaMetaPrivateKey, pem)
62 | setKeys(pem)
63 | } else {
64 | setKeys(privKeyPEMMaybe.String)
65 | }
66 | }
67 |
68 | // VerifyRequestSignature returns true if the request has correct signature. This function makes HTTP requests on your behalf to retrieve the public key.
69 | func VerifyRequestSignature(rq *http.Request, content []byte) bool {
70 | _, err := httpsig.VerifyRequest(rq, content, func(keyID string) (httpsig.PublicKey, error) {
71 | pem := db.KeyPemByID(keyID)
72 | if pem == "" {
73 | // The zero PublicKey has a None key type, which the underlying VerifyRequest handles well.
74 | return httpsig.PublicKey{}, nil
75 | }
76 |
77 | _, pub, err := httpsig.DecodeKey(pem)
78 | return pub, err
79 | })
80 | if err != nil {
81 | log.Printf("When verifying the signature of request to %s got error: %s\n", rq.URL.RequestURI(), err)
82 | return false
83 | }
84 | return true
85 | }
86 |
--------------------------------------------------------------------------------
/fediverse/webfinger.go:
--------------------------------------------------------------------------------
1 | package fediverse
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "git.sr.ht/~bouncepaw/betula/settings"
7 | "git.sr.ht/~bouncepaw/betula/stricks"
8 | "io"
9 | "log"
10 | "net/http"
11 | )
12 |
13 | // https://docs.joinmastodon.org/spec/webfinger/
14 |
15 | type webfingerDocument struct {
16 | Links []struct {
17 | Rel string `json:"rel"`
18 | Type string `json:"type"`
19 | Href string `json:"href"`
20 | } `json:"links"`
21 | }
22 |
23 | func requestIdByWebFingerAcct(user, host string) (id string, err error) {
24 | requestURL := fmt.Sprintf("https://%s/.well-known/webfinger?resource=acct:%s@%s", host, user, host)
25 | req, err := http.NewRequest(http.MethodGet, requestURL, nil)
26 | if err != nil {
27 | log.Printf("Failed to construct request from ‘%s’\n", requestURL)
28 | return "", err
29 | }
30 |
31 | req.Header.Set("User-Agent", settings.UserAgent())
32 | resp, err := client.Do(req)
33 | if err != nil {
34 | return "", err
35 | }
36 |
37 | data, err := io.ReadAll(resp.Body)
38 | if err != nil {
39 | return "", err
40 | }
41 |
42 | obj := webfingerDocument{}
43 | if err = json.Unmarshal(data, &obj); err != nil {
44 | return "", err
45 | }
46 |
47 | for _, link := range obj.Links {
48 | if link.Rel == "self" && link.Type == "application/activity+json" && stricks.ValidURL(link.Href) {
49 | // Found what we were looking for
50 | return link.Href, nil
51 | }
52 | }
53 |
54 | // Mistakes happen
55 | return "", nil
56 | }
57 |
--------------------------------------------------------------------------------
/feeds/feed.go:
--------------------------------------------------------------------------------
1 | // Package feeds manages RSS feed generation.
2 | package feeds
3 |
4 | import (
5 | "fmt"
6 | "git.sr.ht/~bouncepaw/betula/db"
7 | "git.sr.ht/~bouncepaw/betula/myco"
8 | "git.sr.ht/~bouncepaw/betula/settings"
9 | "git.sr.ht/~bouncepaw/betula/types"
10 | "humungus.tedunangst.com/r/webs/rss"
11 | "log"
12 | "strings"
13 | "time"
14 | )
15 |
16 | const rssTimeFormat = time.RFC822
17 |
18 | func fiveLastDays(now time.Time) (days []time.Time, dayStamps []string, dayPosts [][]types.Bookmark) {
19 | days = make([]time.Time, 5)
20 | dayStamps = make([]string, 5)
21 | dayPosts = make([][]types.Bookmark, 5)
22 | for i := 0; i < 5; i++ {
23 | day := now.AddDate(0, 0, -i-1)
24 | day = time.Date(day.Year(), day.Month(), day.Day(), 23, 59, 59, 0, time.UTC)
25 | days[i] = day
26 |
27 | dayStamps[i] = day.Format("2006-01-02")
28 |
29 | dayPosts[i] = db.BookmarksForDay(false, dayStamps[i])
30 | }
31 | return days, dayStamps, dayPosts
32 | }
33 |
34 | func Posts() *rss.Feed {
35 | author := settings.AdminUsername()
36 |
37 | now := time.Now().AddDate(0, 0, 1)
38 | _, _, dayPosts := fiveLastDays(now)
39 |
40 | feed := rss.Feed{
41 | Title: fmt.Sprintf("%s posts", settings.SiteName()),
42 | Link: settings.SiteURL(),
43 | Description: fmt.Sprintf("All public posts are sent to this feed."),
44 | PubDate: now.Format(rssTimeFormat),
45 | Items: []*rss.Item{},
46 | }
47 |
48 | for _, posts := range dayPosts {
49 | for _, post := range posts {
50 | creationTime, err := time.Parse(types.TimeLayout, post.CreationTime)
51 | if err != nil {
52 | log.Printf("The timestamp for post no. %d ‘%s’ is invalid: %s\n",
53 | post.ID, post.Title, post.CreationTime)
54 | continue
55 | }
56 |
57 | var entry = &rss.Item{
58 | Title: post.Title,
59 | Link: post.URL,
60 | Author: author,
61 | Description: rss.CData{
62 | descriptionForOnePost(post),
63 | },
64 | PubDate: creationTime.Format(rssTimeFormat),
65 | }
66 | feed.Items = append(feed.Items, entry)
67 | }
68 | }
69 |
70 | return &feed
71 | }
72 |
73 | func Digest() *rss.Feed {
74 | author := settings.AdminUsername()
75 |
76 | now := time.Now()
77 | days, dayStamps, dayPosts := fiveLastDays(now)
78 |
79 | feed := rss.Feed{
80 | Title: fmt.Sprintf("%s daily digest", settings.SiteName()),
81 | Link: settings.SiteURL(),
82 | Description: fmt.Sprintf("Every day, a list of all links posted that day is sent."),
83 | PubDate: now.Format(rssTimeFormat),
84 | Items: []*rss.Item{},
85 | }
86 |
87 | for i, posts := range dayPosts {
88 | if posts == nil {
89 | continue
90 | }
91 | var entry = &rss.Item{
92 | Title: fmt.Sprintf("%s %s", settings.SiteName(), dayStamps[i]),
93 | Link: fmt.Sprintf("%s/day/%s", settings.SiteURL(), dayStamps[i]),
94 | Author: author,
95 | Description: rss.CData{
96 | descriptionFromPosts(posts, dayStamps[i]),
97 | },
98 | PubDate: days[i].Format(rssTimeFormat),
99 | }
100 | feed.Items = append(feed.Items, entry)
101 | }
102 |
103 | return &feed
104 | }
105 |
106 | const descriptionTemplate = `
107 |
108 | 🔗 %s
109 | %s
110 | %s
111 | `
112 |
113 | func descriptionForOnePost(post types.Bookmark) string {
114 | var tagBuf strings.Builder
115 | for i, tag := range post.Tags {
116 | if i > 0 {
117 | tagBuf.WriteString(", ")
118 | }
119 | tagBuf.WriteString(fmt.Sprintf(`%s `, tag.Name, tag.Name))
120 | }
121 |
122 | return fmt.Sprintf(
123 | descriptionTemplate,
124 | post.URL,
125 | post.Title,
126 | post.URL,
127 | types.CleanerLink(post.URL),
128 | func() string {
129 | if len(post.Tags) > 0 {
130 | return "🏷 " + tagBuf.String() + "
"
131 | }
132 | return ""
133 | }(),
134 | myco.MarkupToHTML(post.Description),
135 | )
136 | }
137 |
138 | func descriptionFromPosts(posts []types.Bookmark, dayStamp string) string {
139 | var buf strings.Builder
140 |
141 | for _, post := range posts {
142 | buf.WriteString(descriptionForOnePost(post))
143 | }
144 |
145 | return buf.String()
146 | }
147 |
--------------------------------------------------------------------------------
/feeds/feed_test.go:
--------------------------------------------------------------------------------
1 | package feeds
2 |
3 | import (
4 | "git.sr.ht/~bouncepaw/betula/db"
5 | "testing"
6 | "time"
7 | )
8 |
9 | func TestFiveLastDays(t *testing.T) {
10 | db.InitInMemoryDB()
11 | db.MoreTestingBookmarks()
12 | days, dayStamps, dayPosts := fiveLastDays(
13 | time.Date(2023, 3, 21, 0, 0, 0, 0, time.UTC))
14 |
15 | _ = days
16 |
17 | correctDayStamps := []string{"2023-03-20", "2023-03-19", "2023-03-18", "2023-03-17", "2023-03-16"}
18 | for i, stamp := range dayStamps {
19 | if correctDayStamps[i] != stamp {
20 | t.Errorf("Incorrect day stamp generated. Got %s, expected %s.", stamp, correctDayStamps[i])
21 | }
22 | }
23 |
24 | correctBookmarkCounts := []int{2, 1, 0, 1, 0}
25 | for i, posts := range dayPosts {
26 | if correctBookmarkCounts[i] != len(posts) {
27 | t.Errorf("Incorrect post count for %s. Got %d, expected %d. Data: %v.", dayStamps[i], len(posts), correctBookmarkCounts[i], posts)
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module git.sr.ht/~bouncepaw/betula
2 |
3 | go 1.23.3
4 |
5 | require (
6 | git.sr.ht/~bouncepaw/mycomarkup/v5 v5.6.0
7 | github.com/mattn/go-sqlite3 v1.14.24
8 | github.com/mileusna/useragent v1.3.5
9 | golang.org/x/crypto v0.32.0
10 | golang.org/x/net v0.34.0
11 | humungus.tedunangst.com/r/webs v0.7.21
12 | )
13 |
14 | require (
15 | codeberg.org/bouncepaw/obelisk-ng v0.10.1 // indirect
16 | github.com/andybalholm/cascadia v1.3.3 // indirect
17 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect
18 | github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
19 | github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
20 | github.com/kennygrant/sanitize v1.2.4 // indirect
21 | github.com/pkg/errors v0.9.1 // indirect
22 | github.com/tdewolff/parse/v2 v2.7.19 // indirect
23 | golang.org/x/sync v0.10.0 // indirect
24 | golang.org/x/text v0.21.0 // indirect
25 | )
26 |
--------------------------------------------------------------------------------
/help/en/errors.myco:
--------------------------------------------------------------------------------
1 | = Error codes
2 | Some parts of Betula user interface expose error codes for easier reference. This is a newer addition, so most of error states have no error codes. Existing ones are listed here.
3 |
4 | == BET-113
5 | This error is specific for federated instances only.
6 |
7 | This error is shown when you're visiting a visiting a Betula page logged-in, and the host you're requesting does not match the host specified in the Settings.
8 |
9 | Most likely, the reverse proxy you are using is not passing the `Host` header along by default. You have to make it do so, otherwise the federation will not fully work.
10 |
11 | For Apache, add the following lines to your `VirtualHost`:
12 |
13 | ```httpd
14 | UseCanonicalName on
15 | ProxyPreserveHost on
16 | ```
17 |
18 | For Angie or nginx, add the following line to your `location /` (where you have the `proxy_pass` directive):
19 |
20 | ```nginx
21 | proxy_set_header Host $host;
22 | ```
23 |
24 | In rarer cases, you might see this error when accessing the instance from a mirror.
25 |
26 | If you want to hide this error notification for any reason, use this custom CSS:
27 |
28 | ```css
29 | .notif[notif-cat="Host mismatch"] { display: none; }
30 | ```
31 |
32 | == BET-114
33 | This error is specific for federated instances only.
34 |
35 | The site address specified in Settings does not start with `https://`. In Fediverse, only HTTPS servers work, so you have to change the address so it uses the correct the protocol, even if you provide HTTP as well.
36 |
37 | You should also have HTTPS properly set up with a reverse proxy. See [[https://betula.mycorrhiza.wiki/https-domain.html | Setting up a domain and HTTPS]].
38 |
39 | If you want to hide this error notification for any reason, use this custom CSS:
40 |
41 | ```css
42 | .notif[notif-cat="Wrong protocol"] { display: none; }
43 | ```
--------------------------------------------------------------------------------
/help/en/index.myco:
--------------------------------------------------------------------------------
1 | = Betula
2 | **Betula** is a free self-hosted single-user bookmarking software for the independent web. Use it to organize bookmarks, references and linklogs.
3 |
4 | Every Betula installation comes with a full copy of this documentation. Use the topic selector to read more about specific topics.
5 |
6 | => https://betula.mycorrhiza.wiki | Official Betula website
7 | => /about | Information about this Betula instance
8 |
9 | == Saving links
10 | Press the [[/save-link | Save link]] link. There are multiple fields there:
11 |
12 | * **URL** is the URL (address) of a web page. This is the only required field.
13 | * **Title** is the title of the post that will be created when you save the link. If you leave it empty, Betula will do its best to quess a title.
14 | * **Description** is a place for excerpts and conclusion from the saved link. Express your opinion here. You can format the text with [[/help/en/mycomarkup | Mycomarkup]].
15 | * Set **visibility** for your post. Make posts with sensitive or too boring information visible only for you.
16 | * **Tags** are separated with commas. Keywords and topics make good tags. Try to add at least one tag.
17 |
18 | == Tags
19 | On [[/tag | Tags]] page you can see all your tags. Unauthorized users only see tags that have public posts. Don't be scared if you end up having hundreds of tags with 1 to 3 posts each. This is pretty normal.
20 |
21 | == Settings
22 | You really should [[/settings | set up your settings]].
23 |
24 | * **Site name** is shown in tab titles and will be used for the future federative capabilities.
25 | * **Site address** is the URL of your Betula. Basically required for installation that have internet connection.
26 | * **Site title** is shown in the top-left corner. It can be different from Site name.
27 | * **Site description** supports Mycomarkup.
28 | * Advanced settings
29 | ** **Network address** and **port** combination sets up where Betula will listen for connection. Defaults are good. Set up your reverse proxy to redirect here.
30 | ** **Custom CSS** is used for visual configuration.
31 |
32 | == Search
33 | There is a searchbar on every page. Use it to retrieve information.
34 |
35 | => /help/en/search | See Advanced search
36 |
37 | == Bookmarklet
38 | It is highly recommended to use the [[/bookmarklet | bookmarklet]]. It lets you save any web page much quicker, bringing your Betula experience to the next level. It works in all desktop browsers and is known to work on iOS Safari. Android browser support remains unclear for now, but it probably works in some of them.
39 |
40 | == RSS feed
41 | You can subscribe to any Betula with any RSS reader. Some browsers also support RSS feeds out of the box. It is much more convenient than visiting the said Betulas manually. There are two kinds of feeds: post feed and digest feed. The latter is probably what you want.
42 |
--------------------------------------------------------------------------------
/help/en/meta.myco:
--------------------------------------------------------------------------------
1 | = Betula metainformation
2 | In this document you can find more detailed information about Betula and how it is done. You don't require to know any of this to operate Betula effectively. You can also think of this document as a kind of FAQ.
3 |
4 | == How do I get my own Betula?
5 | See the installation guide on the official site.
6 |
7 | => https://betula.mycorrhiza.wiki/installation.html
8 |
9 | If you want to go live on the internet, you would probably need a server and a domain. For local usage, nothing is required.
10 |
11 | == Free?
12 | When we say that Betula is free, we mean freedom. The source code is disclosed and cannot be closed by anybody, the development is done in open. If you want to, feel free to participate in the development. We work on SourceHut but Codeberg pull requests and issues are also very welcome. If you want to request something from the developers, these are the places to go as well.
13 |
14 | => https://sr.ht/~bouncepaw/betula
15 | => https://codeberg.org/bouncepaw/betula
16 |
17 | Betula is also free as in no money asked. If you want to support the development financially, consider donating Bouncepaw on Boosty. The donations are used to pay for the infrastructure.
18 |
19 | => https://boosty.to/bouncepaw
20 |
21 | == How is Betula related to Mycorrhiza?
22 | **Mycorrhiza** is a free wiki engine, developed by mostly the same people.
23 |
24 | => https://mycorrhiza.wiki
25 |
26 | Betula is a newer project with easier administration and better technical design, but with a much more limited scope. Many people use both. If you find yourself writing essays in Betula, considering getting a Mycorrhiza. If you find yourself having hundreds of URL:s in your Mycorrhiza, consider getting a Betula.
27 |
28 | Both projects use the same markup language [[/help/en/mycomarkup | Mycomarkup]]. Both are licensed under AGPLv3-only and are written in the Go programming language.
29 |
30 | == Why only Mycomarkup is supported? Are you going to support Markdown?
31 | Mycomarkup served us well in Mycorrhiza. No, no support for other markups is planned.
32 |
33 | => https://mycorrhiza.wiki/hypha/why_mycomarkup | See Mycorrhiza's answer to that question
34 |
--------------------------------------------------------------------------------
/help/en/mycomarkup.myco:
--------------------------------------------------------------------------------
1 | = Mycomarkup
2 | **Mycomarkup** is the markup used by Betula. You cannot use a different one.
3 |
4 | See also:
5 |
6 | => https://mycorrhiza.wiki/help/en/mycomarkup | Mycomarkup in Mycorrhiza
7 |
8 | = Blocks
9 | Mycomarkup provides the following blocks in post and tag descriptions.
10 |
11 | == Paragraph
12 | **Paragraph** is the most basic block. Paragraphs are separated by empty lines:
13 |
14 | ```myco
15 | This is the first paragraph.
16 |
17 | This is the second one.
18 | ```
19 |
20 | > This is the first paragraph.
21 | >
22 | > This is the second one.
23 |
24 | You can include line breaks:
25 |
26 | ```myco
27 | This is
28 | the first paragraph, but the first two words would always be on a separate line.
29 | ```
30 |
31 | > This is
32 | > the first paragraph, but the first two words would always be on a separate line.
33 |
34 | You have the following formatting options for paragraphs:
35 | * **bold** `*\*bold*\*`
36 | * //italic// `/\/italic/\/`
37 | * `code` ` \`code\``
38 | * ++highlight++ `+\+highlight+\+`
39 | * ^^superscript^^ `^\^superscript^\^`
40 | * __subscript__ `_\_subscript_\_`
41 | * ~~deleted~~ `~\~deleted~\~`
42 |
43 | There are inline links:
44 | ```
45 | You can link just like that: https://example.com
46 |
47 | It is a good idea to wrap links in double brackets: [[https://example.com]]. If you want, you can even set a title: [[https://example.com | Example link]].
48 |
49 | You can link local pages. To link the fourth post, use [[/4]]. To link a tag, use [[/tag/tag_name]]. Look at the address bar on the pages you want to link to see the address.
50 | ```
51 |
52 | > You can link just like that: https://example.com
53 | >
54 | > It is a good idea to wrap links in double brackets: [[https://example.com]]. If you want, you can even set a title: [[https://example.com | Example link]].
55 | >
56 | > You can link local pages. To link the fourth post, use [[/4]]. To link a tag, use [[/tag/tag_name]]. Look at the address bar on the pages you want to link to see the address.
57 |
58 | == Quotes
59 | **Quotes** are used to show an excerpt from a different place, most often, the very document linked in the post. Quote the part you like the most, so you can find it later.
60 |
61 | Prepend multiple lines with the `>` character, and they will be shown as a quote:
62 |
63 | ```
64 | Ahab’s voice was heard.
65 |
66 | > Hast seen the White Whale ?
67 | ```
68 |
69 | > Ahab’s voice was heard.
70 | >
71 | > > Hast seen the White Whale ?
72 |
73 | As you can see, the markup examples are shown with quotes in this document as well. It is a good idea to add a space after the `>` character.
74 |
75 | You can quote any Mycomarkup text and nest quotes indefinitely.
76 |
77 | == Rocket links
78 | **Rocket links** are cooler links that are shown on a separate line. Usually used as a ‘see also’-like thing. The linking rules are the same as for the inline links:
79 |
80 | ```myco
81 | => /4
82 | => /tag/programming | I love programming!
83 | => https://example.org
84 | ```
85 |
86 | >=> /4
87 | >=> /tag/programming | I love programming!
88 | >=> https://example.org
89 |
90 | == Image galleries
91 | **Image galleries** show images with optional descriptions:
92 |
93 | ```myco
94 | img {
95 | https://bouncepaw.com/mushroom.jpg
96 | https://bouncepaw.com/mushroom.jpg | 40
97 | https://bouncepaw.com/mushroom.jpg { Optional //description//. }
98 | }
99 | ```
100 |
101 | > img {
102 | > https://bouncepaw.com/mushroom.jpg
103 | > https://bouncepaw.com/mushroom.jpg | 40
104 | > https://bouncepaw.com/mushroom.jpg { Optional //description//. }
105 | > }
106 |
107 | If you write `img side` instead, the image will float to the right on wider screens. If you write `img grid`, the images will be arranged in two columns instead of just one.
108 |
109 | Most often your galleries will have just one image, which was taken from the linked document.
110 |
111 | == Codeblocks
112 | **Codeblocks** are listings of code, program output, preformatted text, etc.
113 |
114 | ```myco
115 | ```
116 | #!/usr/bin/env python
117 | print("Hello, world!")
118 | ```
119 | ```
120 |
121 | > ```
122 | > #!/usr/bin/env python
123 | > print("Hello, world!")
124 | > ```
125 |
126 | == Tables
127 | **Tables** are used to represent tabular date. You would rarely do that in Betula.
128 |
129 | ```myco
130 | table {
131 | ! No. ! Name ! Age! Favorite food
132 | | 1 | Joe | 45 | Pizza
133 | | 2 | Tim | 23 | //Udon//!
134 | | 3 | Meg || N/A
135 | | 4 | Bob | | {
136 | Bob loves **sushi**!
137 |
138 | And he loves the advanced tables in Mycomarkup!
139 | }
140 | }
141 | ```
142 |
143 | == Headings
144 | **Headings** are used to organize information hierarchically. You would rarely need them:
145 | ```myco
146 | = Level 1
147 | == Level 2
148 | === Level 3
149 | ==== Level 4
150 | ```
151 |
152 | == Thematic breaks
153 | **Thematic breaks** are horizontals line that separate information. In Betula, you would rarely need them, as the information is already separated into posts.
154 |
155 | ```myco
156 | ----
157 | ```
158 |
159 | == Unsupported or reserved blocks
160 | Mycomarkup has more features, but not all of them work in Betula. Transclusion does not work, relative linking is too relative, interwiki basically does not exist. There are also some features not mentioned in this document at all. Consider them as easter eggs or bugs.
--------------------------------------------------------------------------------
/help/en/search.myco:
--------------------------------------------------------------------------------
1 | = Advanced search
2 | You can use the search bar to look up posts with a query. There are several tricks for using the search bar which are described in this document.
3 |
4 | == Look for substring
5 | Query: `text`.
6 |
7 | Result: all posts that have `text` in its title, text or URL.
8 |
9 | Notes:
10 | * The text is case-insensitive, so queries `text` and `TEXT` yield the same results.
11 | * If the query is `text1 text2`, the search engine looks for `text1 text2`, not for `text1` and `text2` separately.
12 |
13 | == Require tag
14 | Query: `#tag`, `#tag1 #tag_two`.
15 |
16 | Results: all posts that have all the provided tags at once.
17 |
18 | Notes:
19 | * Use _ instead of spaces in the tag names.
20 | * The tag names are case-insensitive.
21 | * If you look for just one tag and nothing else, you are redirected to that tag's page.
22 |
23 | == Exclude tag
24 | Query: `-#tag`, `-#tag1 -#tag2`.
25 |
26 | Results: all posts that do not have the provided tags.
27 |
28 | Notes:
29 | * Use _ instead of spaces in the tag names.
30 | * The tag names are case-insensitive.
31 | * If you require and exclude the same tag, you get no results.
32 |
33 | == Look for reposts only
34 | Query: `repost:`
35 |
36 | Results: only reposts will be found.
37 |
38 | Notes:
39 | * You can not filter by original author yet, but it is planned later. That's what the colon is there for.
40 |
41 | == Combining
42 | You can combine all the syntaxes in one query.
43 |
44 | * `granny smith #apple`
45 | * `smith -#apple #actor`
46 |
47 | If a tag instruction is inserted between usual text, the text is combined first. So, these are equivalent: `granny #apple smith` and `granny smith #apple`.
48 |
--------------------------------------------------------------------------------
/help/help.go:
--------------------------------------------------------------------------------
1 | // Package help manages the built-in documentation.
2 | package help
3 |
4 | import (
5 | "embed"
6 | "git.sr.ht/~bouncepaw/betula/myco"
7 | "html/template"
8 | "log"
9 | )
10 |
11 | type Topic struct {
12 | Name string
13 | SidebarTitle string
14 | Rendered template.HTML
15 | }
16 |
17 | var (
18 | //go:embed en/*
19 | english embed.FS
20 | Topics = []Topic{
21 | {"index", "Betula introduction", ""},
22 | {"meta", "Metainformation", ""},
23 | {"mycomarkup", "Mycomarkup formatting", ""},
24 | {"search", "Advanced search", ""},
25 | {"errors", "Error codes", ""},
26 | }
27 | )
28 |
29 | func init() {
30 | for i, topic := range Topics {
31 | raw, err := english.ReadFile("en/" + topic.Name + ".myco")
32 | if err != nil {
33 | log.Fatalln(err)
34 | }
35 |
36 | topic.Rendered = myco.MarkupToHTML(string(raw))
37 | Topics[i] = topic
38 | }
39 | }
40 |
41 | // GetEnglishHelp returns English-language help for the given topic.
42 | func GetEnglishHelp(topicName string) (topic Topic, found bool) {
43 | for _, topic := range Topics {
44 | if topic.Name == topicName {
45 | return topic, true
46 | }
47 | }
48 | return
49 | }
50 |
--------------------------------------------------------------------------------
/jobs/Adding a new job.md:
--------------------------------------------------------------------------------
1 | # How to add a new job?
2 | 1. See `jobtype.go`, add a new job category there. Be descriptive. Do not change the string values ever. Not worth the hassle.
3 | 2. In `jobs/implementations.go`, add the category to `catmap` and map it to a receiver function which shall lie in the same file.
4 | 3. Use functions `ScheduleJSON` and `ScheduleDatum` to schedule jobs.
5 |
6 | If your job is not making any expensive operations such as network requests or many database requests, then you probably should not make a job.
--------------------------------------------------------------------------------
/jobs/jobs.go:
--------------------------------------------------------------------------------
1 | // Package jobs handles behind-the-scenes scheduled stuff.
2 | //
3 | // It makes sense to call all functions here in a separate goroutine.
4 | package jobs
5 |
6 | import (
7 | "bytes"
8 | "encoding/json"
9 | "fmt"
10 | "git.sr.ht/~bouncepaw/betula/db"
11 | "git.sr.ht/~bouncepaw/betula/fediverse/signing"
12 | "git.sr.ht/~bouncepaw/betula/jobs/jobtype"
13 | "git.sr.ht/~bouncepaw/betula/settings"
14 | "git.sr.ht/~bouncepaw/betula/stricks"
15 | "git.sr.ht/~bouncepaw/betula/types"
16 | "log"
17 | "net/http"
18 | "time"
19 | )
20 |
21 | var jobch = make(chan jobtype.Job)
22 |
23 | var client = http.Client{
24 | Timeout: time.Second * 5,
25 | }
26 |
27 | // ScheduleDatum schedules a job with the given category and data of any type, which will be saved as is.
28 | //
29 | // TODO: get rid of it.
30 | func ScheduleDatum(category jobtype.JobCategory, data any) {
31 | job := jobtype.Job{
32 | Category: category,
33 | Payload: data,
34 | }
35 | id := db.PlanJob(job)
36 | job.ID = id
37 | jobch <- job
38 | }
39 |
40 | // ScheduleJSON schedules a job with the given category and data, which will be marshaled into JSON before saving to database. This is the one you should use, unlike ScheduleDatum.
41 | func ScheduleJSON(category jobtype.JobCategory, dataJSON any) {
42 | data, err := json.Marshal(dataJSON)
43 | if err != nil {
44 | log.Printf("While scheduling a %s job: %s\n", category, err)
45 | return
46 | }
47 | ScheduleDatum(category, data)
48 | }
49 |
50 | func ListenAndWhisper() {
51 | lateJobs := db.LoadAllJobs()
52 | go func() {
53 | for job := range jobch {
54 | log.Printf("Received job no. %d ‘%s’\n", job.ID, job.Category)
55 | if jobber, ok := catmap[job.Category]; !ok {
56 | fmt.Printf("An unhandled job category came in: %s\n", job.Category)
57 | } else {
58 | jobber(job)
59 | }
60 | db.DropJob(job.ID)
61 | }
62 | }()
63 | for _, job := range lateJobs {
64 | jobch <- job
65 | }
66 | }
67 |
68 | // TODO: Move to a proper place
69 | func SendActivityToInbox(activity []byte, inbox string) error {
70 | rq, err := http.NewRequest(http.MethodPost, inbox, bytes.NewReader(activity))
71 | if err != nil {
72 | log.Println(err)
73 | return err
74 | }
75 |
76 | rq.Header.Set("User-Agent", settings.UserAgent())
77 | rq.Header.Set("Content-Type", types.ActivityType)
78 | signing.SignRequest(rq, activity)
79 |
80 | log.Printf("Sending activity %s to %s\n", string(activity), inbox)
81 | resp, err := client.Do(rq)
82 | if err != nil {
83 | log.Println(err)
84 | return err
85 | }
86 | if resp.StatusCode != http.StatusOK {
87 | log.Printf("Activity sent to %s returned %d status\n", inbox, resp.StatusCode)
88 | }
89 | return nil
90 | }
91 |
92 | func SendQuietActivityToInbox(activity []byte, inbox string) error {
93 | rq, err := http.NewRequest(http.MethodPost, inbox, bytes.NewReader(activity))
94 | if err != nil {
95 | log.Println(err)
96 | return err
97 | }
98 |
99 | rq.Header.Set("Content-Type", types.ActivityType)
100 | signing.SignRequest(rq, activity)
101 |
102 | log.Printf("Sending activity to %s\n", inbox)
103 | resp, err := client.Do(rq)
104 | if err != nil {
105 | log.Println(err)
106 | return err
107 | }
108 | if resp.StatusCode != http.StatusOK {
109 | log.Printf("Activity sent to %s returned %d status\n", inbox, resp.StatusCode)
110 | }
111 | return nil
112 | }
113 |
114 | func sendActivity(uri string, activity []byte) error {
115 | url := stricks.ParseValidURL(uri)
116 | inbox := fmt.Sprintf("%s://%s/inbox", url.Scheme, url.Host)
117 | return SendActivityToInbox(activity, inbox)
118 | }
119 |
--------------------------------------------------------------------------------
/jobs/jobtype/jobtype.go:
--------------------------------------------------------------------------------
1 | // Package jobtype holds types for jobs and their categories.
2 | package jobtype
3 |
4 | import "time"
5 |
6 | // If you make something drastic to this file, reflect the changes in Adding a new job.md
7 |
8 | type JobCategory string
9 |
10 | const (
11 | SendAnnounce JobCategory = "notify about my repost"
12 | ReceiveAnnounce JobCategory = "verify their repost"
13 | ReceiveUndoAnnounce JobCategory = "receive unrepost"
14 | SendUndoAnnounce JobCategory = "notify about my unrepost"
15 |
16 | /* I changed the style from now. The new style is below. */
17 |
18 | SendAcceptFollow JobCategory = "Send Accept{Follow}"
19 | SendRejectFollow JobCategory = "Send Reject{Follow}"
20 | ReceiveAcceptFollow JobCategory = "Receive Accept{Follow}"
21 | ReceiveRejectFollow JobCategory = "Receive Reject{Follow}"
22 | SendCreateNote JobCategory = "Send Create{Note}"
23 | SendUpdateNote JobCategory = "Send Update{Note}"
24 | SendDeleteNote JobCategory = "Send Delete{Note}"
25 | )
26 |
27 | // Job is a task for Betula to do later.
28 | type Job struct {
29 | // ID is a unique identifier for the Job. You get it when reading from the database. Do not set it when issuing a new job.
30 | ID int64
31 | Category JobCategory
32 | Due time.Time
33 | // Payload is some data.
34 | Payload any
35 | }
36 |
--------------------------------------------------------------------------------
/myco/myco.go:
--------------------------------------------------------------------------------
1 | // Package myco wraps Mycomarkup invocation.
2 | package myco
3 |
4 | import (
5 | "git.sr.ht/~bouncepaw/mycomarkup/v5"
6 | "git.sr.ht/~bouncepaw/mycomarkup/v5/mycocontext"
7 | "git.sr.ht/~bouncepaw/mycomarkup/v5/options"
8 | "html/template"
9 | )
10 |
11 | var opts = options.Options{
12 | HyphaName: "",
13 | WebSiteURL: "",
14 | TransclusionSupported: false,
15 | RedLinksSupported: false,
16 | InterwikiSupported: false,
17 | }.FillTheRest()
18 |
19 | func MarkupToHTML(text string) template.HTML {
20 | ctx, _ := mycocontext.ContextFromStringInput(text, opts)
21 | return template.HTML(mycomarkup.BlocksToHTML(ctx, mycomarkup.BlockTree(ctx)))
22 | }
23 |
--------------------------------------------------------------------------------
/readpage/readpage_test.go:
--------------------------------------------------------------------------------
1 | package readpage
2 |
3 | import (
4 | "embed"
5 | "golang.org/x/net/html"
6 | "io"
7 | "reflect"
8 | "strings"
9 | "testing"
10 | )
11 |
12 | //go:embed testdata/*
13 | var testdata embed.FS
14 |
15 | func TestTrickyURL(t *testing.T) {
16 | f, err := testdata.Open("testdata/h-entry with substituted url.html")
17 | if err != nil {
18 | panic(err)
19 | }
20 | docb, err := io.ReadAll(f)
21 | if err != nil {
22 | panic(err)
23 | }
24 | doc := string(docb)
25 | tricks := []string{
26 | `https://willcrichton.net/notes/portable-epubs#epub-content%2FEPUB%2Findex.xhtml$`,
27 | `https://garden.bouncepaw.com/#Fresh_свежак_freŝa`,
28 | }
29 | for _, trick := range tricks {
30 | txt := strings.ReplaceAll(doc, "BETULA", trick)
31 | ht, err := html.Parse(strings.NewReader(txt))
32 | if err != nil {
33 | panic(err)
34 | }
35 |
36 | data, err := findData("https://bouncepaw.com", []worker{listenForBookmarkOf}, ht)
37 | if err != nil {
38 | panic(err)
39 | }
40 | if data.BookmarkOf != trick {
41 | t.Errorf("Got %q want %q", data.BookmarkOf, trick)
42 | }
43 | }
44 | }
45 |
46 | func TestTitles(t *testing.T) {
47 | table := map[string]string{
48 | "title outside head": "A title!",
49 | "title none": "",
50 | }
51 | for name, expectedTitle := range table {
52 | file, _ := testdata.Open("testdata/" + name + ".html")
53 | doc, _ := html.Parse(file)
54 | data, err := findData("https://bouncepaw.com", titleWorkers, doc)
55 | if data.title != expectedTitle {
56 | t.Errorf("In ‘%s’, expected title ‘%s’, got ‘%s’. Error value is ‘%v’.",
57 | name, expectedTitle, data.title, err)
58 | }
59 | }
60 | }
61 |
62 | func TestHEntries(t *testing.T) {
63 | gutenberg := "https://www.gutenberg.org/files/2701/2701-h/2701-h.htm#link2HCH0001"
64 | mushatlas := "https://mushroomcoloratlas.com/"
65 |
66 | table := map[string]FoundData{
67 | "h-entry with p-name": {
68 | PostName: "CHAPTER 1. Loomings.",
69 | BookmarkOf: "",
70 | Tags: nil,
71 | Mycomarkup: "",
72 | IsHFeed: false,
73 | },
74 |
75 | "h-entry with p-name u-bookmark-of": {
76 | PostName: "CHAPTER 1. Loomings.",
77 | BookmarkOf: gutenberg,
78 | Tags: nil,
79 | Mycomarkup: "",
80 | IsHFeed: false,
81 | },
82 |
83 | "h-feed with h-entries": {
84 | PostName: "CHAPTER 1. Loomings.",
85 | BookmarkOf: "",
86 | Tags: nil,
87 | Mycomarkup: "",
88 | IsHFeed: true,
89 | },
90 |
91 | "mycomarkup linked": {
92 | PostName: "Mushroom color atlas",
93 | BookmarkOf: mushatlas,
94 | Tags: []string{"myco"},
95 | Mycomarkup: "Many cool colors",
96 | IsHFeed: false,
97 | },
98 | }
99 |
100 | for name, expectedData := range table {
101 | file, _ := testdata.Open("testdata/" + name + ".html")
102 | doc, _ := html.Parse(file)
103 | data, err := findData("https://bouncepaw.com", makeRepostWorkers, doc)
104 | data.docurl = nil
105 | if !reflect.DeepEqual(data, expectedData) {
106 | t.Errorf("In ‘%s’,\nwant %v,\ngot %v. Error value is ‘%v’.",
107 | name, expectedData, data, err)
108 | }
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/readpage/testdata/h-entry with p-name u-bookmark-of.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Title
6 |
7 |
8 | This document is notorious for having an h-entry with p-name and u-bookmark-of in it. There is no h-feed here.
9 |
10 |
11 | CHAPTER 1. Loomings.
12 | Taken from here
13 | Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.
14 |
15 |
16 |
--------------------------------------------------------------------------------
/readpage/testdata/h-entry with p-name.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | h-entry doc
6 |
7 |
8 | This document is notorious for having an h-entry with p-name in it. There is no h-feed here.
9 |
10 |
11 | CHAPTER 1. Loomings.
12 | Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.
13 |
14 |
15 |
--------------------------------------------------------------------------------
/readpage/testdata/h-entry with substituted url.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | h-entry doc
6 |
7 |
8 | This document is notorious for having an h-entry with p-name in it. There is no h-feed here.
9 |
10 |
11 | CHAPTER 1. Loomings.
12 | url
13 | Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.
14 |
15 |
16 |
--------------------------------------------------------------------------------
/readpage/testdata/h-feed with h-entries.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | h-feed
6 |
7 |
8 |
9 |
10 | CHAPTER 1. Loomings.
11 |
12 |
13 | CHAPTER 2. The Carpet-Bag.
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/readpage/testdata/mycomarkup linked.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Mycomarkup linked
6 |
7 |
8 |
9 | Cut the head!
10 | Mushroom color atlas
11 | link
12 | myco
13 |
14 |
--------------------------------------------------------------------------------
/readpage/testdata/title none.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | This document is notorious for its lack of the TITLE element. There is no H1 as well.
4 |
5 |
6 |
--------------------------------------------------------------------------------
/readpage/testdata/title outside head.html:
--------------------------------------------------------------------------------
1 |
2 | A title!
3 |
4 | This document is notorious for having the TITLE outside of HEAD. It is perfectly valid if there is no HEAD at all.
5 |
6 |
7 |
--------------------------------------------------------------------------------
/roar-activities.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | SallyCreated=$(
4 | cat << 'EOT'
5 | {
6 | "@context": "https://www.w3.org/ns/activitystreams",
7 | "summary": "Sally created a note",
8 | "type": "Create",
9 | "actor": {
10 | "type": "Person",
11 | "name": "Sally"
12 | },
13 | "object": {
14 | "type": "Note",
15 | "name": "A Simple Note",
16 | "content": "This is a simple note"
17 | }
18 | }
19 | EOT
20 | )
21 |
22 | SallyAnnouncedArrival=$(
23 | cat << 'EOT'
24 | {
25 | "@context": "https://www.w3.org/ns/activitystreams",
26 | "summary": "Sally announced that she had arrived at work",
27 | "type": "Announce",
28 | "actor": {
29 | "type": "Person",
30 | "id": "http://sally.example.org",
31 | "name": "Sally"
32 | },
33 | "object": {
34 | "type": "Arrive",
35 | "actor": "http://sally.example.org",
36 | "location": {
37 | "type": "Place",
38 | "name": "Work"
39 | }
40 | }
41 | }
42 | EOT
43 | )
44 |
45 | MeaningfulAnnounce=$(
46 | cat << 'EOT'
47 | {
48 | "@context": "https://www.w3.org/ns/activitystreams",
49 | "type": "Announce",
50 | "object": "https://bouncepaw.com/url of your post",
51 | "url": "https://parasocial.mycorrhiza.wiki/23"
52 | }
53 | EOT
54 | )
55 |
56 | Inbox='http://localhost:1738/inbox'
57 |
58 |
59 | Post() {
60 | Data=$1
61 | shift
62 | curl -isS "$@" "$Inbox" -X POST --header "Content-Type: application/activity+json" --data "$Data"
63 | }
64 |
65 | Post "Junk"
66 | Post "$SallyCreated"
67 | Post "$SallyAnnouncedArrival"
68 | Post "$MeaningfulAnnounce"
--------------------------------------------------------------------------------
/search/search.go:
--------------------------------------------------------------------------------
1 | package search
2 |
3 | import (
4 | "git.sr.ht/~bouncepaw/betula/db"
5 | "git.sr.ht/~bouncepaw/betula/types"
6 | "regexp"
7 | )
8 |
9 | var (
10 | // TODO: Exclude more characters
11 | excludeTagRe = regexp.MustCompile(`-#([^?!:#@<>*|'"&%{}\\\s]+)\s*`)
12 | includeTagRe = regexp.MustCompile(`#([^?!:#@<>*|'"&%{}\\\s]+)\s*`)
13 |
14 | // TODO: argument will be added in a future version
15 | includeRepostRe = regexp.MustCompile(`\brepost:()\s*`)
16 | )
17 |
18 | func ForFederated(query string, offset, limit uint) (bookmarks []types.Bookmark, totalResults uint) {
19 | if limit > types.BookmarksPerPage {
20 | limit = types.BookmarksPerPage
21 | }
22 |
23 | query, excludedTags := extractWithRegex(query, excludeTagRe)
24 | query, includedTags := extractWithRegex(query, includeTagRe)
25 |
26 | return db.SearchOffset(query, includedTags, excludedTags, offset, limit)
27 | }
28 |
29 | // For searches for the given query.
30 | func For(query string, authorized bool, page uint) (postsInPage []types.Bookmark, totalPosts uint) {
31 | // We extract excluded tags first.
32 | query, excludedTags := extractWithRegex(query, excludeTagRe)
33 | query, includedTags := extractWithRegex(query, includeTagRe)
34 | query, includedRepostMarkers := extractWithRegex(query, includeRepostRe)
35 |
36 | return db.Search(query, includedTags, excludedTags, len(includedRepostMarkers) != 0, authorized, page)
37 | }
38 |
39 | func extractWithRegex(query string, regex *regexp.Regexp) (string, []string) {
40 | var extracted []string
41 | for _, result := range regex.FindAllStringSubmatch(query, -1) {
42 | result := result
43 | extracted = append(extracted, result[1])
44 | }
45 | query = regex.ReplaceAllString(query, "")
46 | return query, extracted
47 | }
48 |
--------------------------------------------------------------------------------
/settings/How to add a new Setting.md:
--------------------------------------------------------------------------------
1 | # How to add a new Setting?
2 |
3 | 2023-06-28 I decided to add a new Setting: Custom CSS. Thought that I should document it for a future Settingist.
4 |
5 | 2025-05-14 When implementing Custom JS, the document was brought to speed.
6 |
7 | As you can see, there are quite a lot of steps to add a new setting. That should discourage you from adding settings. No one really wants them.
8 |
9 | ## Update `types.Settings`
10 | In `types.go`:
11 |
12 | ```go
13 | package types
14 |
15 | // ...
16 |
17 | type Settings struct {
18 | // ...
19 | CustomCSS string
20 | }
21 | ```
22 |
23 | ## Update form
24 | In `settings.gohtml`:
25 |
26 | ```html
27 |
28 |
Custom CSS
29 |
30 |
31 | This stylesheet will be served right after the original Betula stylesheet.
32 |
33 |
34 | ```
35 |
36 | The ` `'s `id` and `name` should be the same to confuse people less.
37 |
38 | ## Add DB key
39 |
40 | In `betula-meta-keys.go` add a key for the entry in `BetulaMeta` table. When choosing the key, try to come up with something that makes when looked at in the database.
41 |
42 | ```go
43 | package db
44 |
45 | type BetulaMetaKey string
46 |
47 | const (
48 | // ...
49 | BetulaMetaCustomCSS BetulaMetaKey = "Custom CSS"
50 | )
51 | ```
52 |
53 | ## Update `settings` package
54 | In `settings.go` set up the cached getter for the setting, update `SetSettings`
55 |
56 | ```go
57 | package settings
58 |
59 | // ...
60 |
61 | func Index() {
62 | // ...
63 | cache.CustomCSS = db.MetaEntry[string](db.BetulaMetaCustomCSS)
64 | }
65 |
66 | // the cached getter:
67 | func CustomCSS() string { return cache.CustomCSS }
68 |
69 | func SetSettings(settings types.Settings) {
70 | // ...
71 | db.SetMetaEntry(db.BetulaMetaCustomCSS, settings.CustomCSS)
72 | Index()
73 | }
74 | ```
75 |
76 | ## Update the handler
77 | In `handlers.go` in `getSettings` and `postSettings` mention the new field of `types.Settings` everywhere while making sense.
78 |
79 | ## Implement the feature
80 | There is no guide for that, every feature is unique.
81 |
82 | ## Test
83 | Manual test is mandatory, an automatic test would be even better!
--------------------------------------------------------------------------------
/stricks/stricks.go:
--------------------------------------------------------------------------------
1 | // Package stricks (string tricks) provides common string operations that looked like they belong here.
2 | package stricks
3 |
4 | import (
5 | "fmt"
6 | "math/rand"
7 | "net/url"
8 | "time"
9 | )
10 |
11 | func ValidURL(s string) bool {
12 | _, err := url.ParseRequestURI(s)
13 | return err == nil
14 | }
15 |
16 | func ParseValidURL(s string) *url.URL {
17 | u, err := url.ParseRequestURI(s)
18 | if err != nil {
19 | panic(err)
20 | }
21 | return u
22 | }
23 |
24 | func SameHost(s1, s2 string) bool {
25 | u1, err1 := url.ParseRequestURI(s1)
26 | u2, err2 := url.ParseRequestURI(s2)
27 | return err1 == nil && err2 == nil && u1.Host == u2.Host
28 | }
29 |
30 | func StringifyAnything(o any) string {
31 | switch s := o.(type) {
32 | case string:
33 | return s
34 | default:
35 | return ""
36 | }
37 | }
38 |
39 | func RandomWhatever() string {
40 | b := make([]byte, 20)
41 | rand.Read(b)
42 | return fmt.Sprintf("%x", b)[2:20]
43 | }
44 |
45 | func init() {
46 | rand.Seed(time.Now().UnixNano())
47 | }
48 |
--------------------------------------------------------------------------------
/tools/last_seen.go:
--------------------------------------------------------------------------------
1 | package tools
2 |
3 | import (
4 | "fmt"
5 | "time"
6 | )
7 |
8 | func LastSeen(from, to time.Time) string {
9 |
10 | format := func(s string, count int) string {
11 | return fmt.Sprintf("%s ago", pluralize(s, count))
12 | }
13 |
14 | diff := to.Sub(from)
15 |
16 | if diff.Seconds() < 1 {
17 | return "just now"
18 | }
19 | if diff.Minutes() < 1 {
20 | return format("second", int(diff.Seconds()))
21 | }
22 | if diff.Hours() < 1 {
23 | return format("minute", int(diff.Minutes()))
24 | }
25 | if diff.Hours() < 24 {
26 | return format("hour", int(diff.Hours()))
27 | }
28 | if diff.Hours() < 24*7 {
29 | return format("day", int(diff.Hours()/24))
30 | }
31 | if diff.Hours() == 24*7 {
32 | return "a week ago"
33 | }
34 | return fmt.Sprintf("on %s", from.Format("Monday, January 2, 2006"))
35 | }
36 |
37 | func pluralize(s string, count int) string {
38 | if count == 1 {
39 | if s == "hour" {
40 | return fmt.Sprintf("an %s", s)
41 | }
42 | return fmt.Sprintf("a %s", s)
43 | }
44 | return fmt.Sprintf("%d %ss", count, s)
45 | }
46 |
--------------------------------------------------------------------------------
/tools/last_seen_test.go:
--------------------------------------------------------------------------------
1 | package tools
2 |
3 | import (
4 | "testing"
5 | "time"
6 | )
7 |
8 | func TestLastSeen(t *testing.T) {
9 | toTime := time.Date(2023, 3, 21, 0, 0, 0, 0, time.UTC)
10 |
11 | testCases := []struct {
12 | name string
13 | fromTime time.Time
14 | expected string
15 | }{
16 | {
17 | name: "just now",
18 | fromTime: toTime.Add(-time.Millisecond * 10),
19 | expected: "just now",
20 | },
21 | {
22 | name: "a second ago",
23 | fromTime: toTime.Add(-1 * time.Second),
24 | expected: "a second ago",
25 | },
26 | {
27 | name: "seconds ago",
28 | fromTime: toTime.Add(-10 * time.Second),
29 | expected: "10 seconds ago",
30 | },
31 | {
32 | name: "a minute ago",
33 | fromTime: toTime.Add(-1 * time.Minute),
34 | expected: "a minute ago",
35 | },
36 | {
37 | name: "minutes ago",
38 | fromTime: toTime.Add(-3 * time.Minute),
39 | expected: "3 minutes ago",
40 | },
41 | {
42 | name: "an hour ago",
43 | fromTime: toTime.Add(-1 * time.Hour),
44 | expected: "an hour ago",
45 | },
46 | {
47 | name: "hours ago",
48 | fromTime: toTime.Add(-2 * time.Hour),
49 | expected: "2 hours ago",
50 | },
51 | {
52 | name: "a day ago",
53 | fromTime: toTime.Add(-24 * time.Hour),
54 | expected: "a day ago",
55 | },
56 | {
57 | name: "days ago",
58 | fromTime: toTime.Add(-3 * 24 * time.Hour),
59 | expected: "3 days ago",
60 | },
61 | {
62 | name: "a week ago",
63 | fromTime: toTime.Add(-7 * 24 * time.Hour),
64 | expected: "a week ago",
65 | },
66 | {
67 | name: "weeks ago",
68 | fromTime: toTime.Add(-14 * 24 * time.Hour),
69 | expected: "on Tuesday, March 7, 2023",
70 | },
71 | }
72 |
73 | for _, tc := range testCases {
74 | t.Run(tc.name, func(t *testing.T) {
75 | result := LastSeen(tc.fromTime, toTime)
76 | if result != tc.expected {
77 | t.Errorf("Expected %s, but got %s", tc.expected, result)
78 | }
79 | })
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/tools/slice.go:
--------------------------------------------------------------------------------
1 | package tools
2 |
3 |
4 | func insertElement[T any](array []T, value T, index int) []T {
5 | return append(array[:index], append([]T{value}, array[index:]...)...)
6 | }
7 |
8 | func removeElement[T any](array []T, index int) []T {
9 | return append(array[:index], array[index+1:]...)
10 | }
11 |
12 | func MoveElement[T any](array []T, srcIndex int, dstIndex int) []T {
13 | value := array[srcIndex]
14 | return insertElement(removeElement(array, srcIndex), value, dstIndex)
15 | }
--------------------------------------------------------------------------------
/types/ap.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "database/sql"
5 | "fmt"
6 | "html/template"
7 | "time"
8 |
9 | "git.sr.ht/~bouncepaw/betula/stricks"
10 | )
11 |
12 | const ActivityType = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
13 | const OtherActivityType = "application/activity+json"
14 |
15 | type Actor struct {
16 | ID string `json:"id"`
17 | Inbox string `json:"inbox"`
18 | PreferredUsername string `json:"preferredUsername"`
19 | DisplayedName string `json:"name"`
20 | Summary string `json:"summary,omitempty"`
21 | PublicKey struct {
22 | ID string `json:"id"`
23 | Owner string `json:"owner"`
24 | PublicKeyPEM string `json:"publicKeyPem"`
25 | } `json:"publicKey,omitempty"`
26 |
27 | SubscriptionStatus SubscriptionRelation `json:"-"` // Set manually
28 | Domain string `json:"-"` // Set manually
29 | }
30 |
31 | func (a *Actor) Valid() bool {
32 | urlsOK := stricks.ValidURL(a.ID) && stricks.ValidURL(a.Inbox) && stricks.ValidURL(a.PublicKey.Owner)
33 | nonEmpty := a.PreferredUsername != "" && a.PublicKey.PublicKeyPEM != "" && a.Domain != ""
34 | return urlsOK && nonEmpty
35 | }
36 |
37 | func (a Actor) Acct() string {
38 | return fmt.Sprintf("@%s@%s", a.PreferredUsername, a.Domain)
39 | }
40 |
41 | type SubscriptionRelation string
42 |
43 | const (
44 | SubscriptionNone SubscriptionRelation = ""
45 | SubscriptionTheyFollow SubscriptionRelation = "follower"
46 | SubscriptionIFollow SubscriptionRelation = "following"
47 | SubscriptionMutual SubscriptionRelation = "mutual"
48 | SubscriptionPending SubscriptionRelation = "pending"
49 | SubscriptionPendingMutual SubscriptionRelation = "pending mutual" // yours pending, theirs accepted
50 | )
51 |
52 | func (sr SubscriptionRelation) IsPending() bool {
53 | return sr == SubscriptionPending || sr == SubscriptionPendingMutual
54 | }
55 |
56 | func (sr SubscriptionRelation) TheyFollowUs() bool {
57 | return sr == SubscriptionTheyFollow || sr == SubscriptionMutual || sr == SubscriptionPendingMutual
58 | }
59 |
60 | func (sr SubscriptionRelation) WeFollowThem() bool {
61 | // TODO: if our request is pending, but we receive a post from them, does it mean they accepted?
62 | return sr == SubscriptionIFollow || sr == SubscriptionMutual || sr == SubscriptionPendingMutual || sr == SubscriptionPending
63 | }
64 |
65 | type RemoteBookmark struct {
66 | ID string
67 | RepostOf sql.NullString
68 | ActorID string
69 |
70 | Title string
71 | URL string
72 | DescriptionHTML template.HTML
73 | DescriptionMycomarkup sql.NullString
74 | PublishedAt string
75 | UpdatedAt sql.NullString
76 | Activity []byte
77 |
78 | Tags []Tag
79 | }
80 |
81 | type RenderedRemoteBookmark struct {
82 | ID string
83 |
84 | AuthorAcct string
85 | AuthorDisplayedName string
86 | RepostOf sql.NullString
87 |
88 | Title string
89 | URL string
90 | Description template.HTML
91 | Tags []Tag
92 | PublishedAt time.Time
93 | }
94 |
95 | type RemoteBookmarkGroup struct {
96 | Date string
97 | Bookmarks []RenderedRemoteBookmark
98 | }
99 |
100 | var remoteCutoff RenderedRemoteBookmark = (func() RenderedRemoteBookmark {
101 | bigtime := "9999-01-02T15:04:05+07:00"
102 | t, err := time.Parse(time.RFC3339, bigtime)
103 | if err != nil {
104 | panic(err)
105 | }
106 | return RenderedRemoteBookmark{PublishedAt: t}
107 | })()
108 |
109 | // GroupRemoteBookmarksByDate groups the bookmarks by date. The dates are strings like 2006-01-02T15:04:05Z07:00 (ActivityPub-style). This function expects the input bookmarks to be sorted by date.
110 | func GroupRemoteBookmarksByDate(ungroupedBookmarks []RenderedRemoteBookmark) (groupedBookmarks []RemoteBookmarkGroup) {
111 | if len(ungroupedBookmarks) == 0 {
112 | return nil
113 | }
114 |
115 | ungroupedBookmarks = append(ungroupedBookmarks, remoteCutoff)
116 |
117 | var (
118 | currentDate string
119 | currentBookmarks []RenderedRemoteBookmark
120 | )
121 |
122 | for _, bookmark := range ungroupedBookmarks {
123 | if bookmark.PublishedAt.Format(time.DateOnly) != currentDate {
124 | if currentBookmarks != nil {
125 | groupedBookmarks = append(groupedBookmarks, RemoteBookmarkGroup{
126 | Date: currentDate,
127 | Bookmarks: currentBookmarks,
128 | })
129 | }
130 | currentDate = bookmark.PublishedAt.Format(time.DateOnly)
131 | currentBookmarks = nil
132 | }
133 |
134 | currentBookmarks = append(currentBookmarks, bookmark)
135 | }
136 |
137 | return
138 | }
139 |
--------------------------------------------------------------------------------
/types/archiving.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "bytes"
5 | "compress/gzip"
6 | "crypto/sha256"
7 | "database/sql"
8 | "encoding/base64"
9 | "fmt"
10 | "regexp"
11 | "strings"
12 | )
13 |
14 | // Artifact is an artifact in any format stored in database.
15 | type Artifact struct {
16 | ID string
17 | MimeType string
18 | Data []byte
19 | IsGzipped bool
20 | Size int
21 | }
22 |
23 | // NewCompressedDocumentArtifact makes an Artifact from the given
24 | // uncompressed document. Artifact.ID is a base64 representation
25 | // of an SHA-256 hash sum of the document contents. Artifact.MimeType
26 | // is the source MIME type. Artifact.IsGzipped is true.
27 | // Artifact.Data is gzipped document contents.
28 | //
29 | // Gzip was chosen because it's the most widely accepted content
30 | // compression algorithm in browsers. This way, we can deliver
31 | // the document without intermediary recompression.
32 | func NewCompressedDocumentArtifact(b []byte, mime string) (*Artifact, error) {
33 | var id string
34 | {
35 | var hash = sha256.New()
36 | var _, err = hash.Write(b)
37 | if err != nil {
38 | return nil, fmt.Errorf("failed to write bytes to sha256: %w", err)
39 | }
40 |
41 | var buf strings.Builder
42 | var encoder = base64.NewEncoder(base64.RawURLEncoding, &buf)
43 |
44 | _, err = encoder.Write(hash.Sum(nil))
45 | if err != nil {
46 | return nil, fmt.Errorf("failed to calculate base64 hash sum: %w", err)
47 | }
48 | err = encoder.Close()
49 | if err != nil {
50 | return nil, fmt.Errorf("failed to calculate base64 hash sum: %w", err)
51 | }
52 |
53 | id = buf.String()
54 | }
55 |
56 | var gzipped []byte
57 | {
58 | var buf bytes.Buffer
59 | var gzipper = gzip.NewWriter(&buf)
60 |
61 | var _, err = gzipper.Write(b)
62 | if err != nil {
63 | return nil, fmt.Errorf("failed to compress artifact: %w", err)
64 | }
65 | err = gzipper.Close()
66 | if err != nil {
67 | return nil, fmt.Errorf("failed to compress artifact: %w", err)
68 | }
69 |
70 | gzipped = buf.Bytes()
71 | }
72 |
73 | return &Artifact{
74 | ID: id,
75 | MimeType: mime,
76 | Data: gzipped,
77 | IsGzipped: true,
78 | }, nil
79 | }
80 |
81 | func (a *Artifact) HumanSize() string {
82 | switch {
83 | case a.Size == 0:
84 | return "empty"
85 | case a.Size < 1024:
86 | return fmt.Sprintf("%d B", a.Size)
87 | case a.Size < 1024*1024:
88 | return fmt.Sprintf("%.2f KiB", float64(a.Size)/float64(1024))
89 | default:
90 | return fmt.Sprintf("%.2f MiB", float64(a.Size)/float64(1024*1024))
91 | }
92 | }
93 |
94 | var reMime = regexp.MustCompile(`[a-z]+/([a-z]+).*`)
95 |
96 | func (a *Artifact) HumanMimeType() string {
97 | matches := reMime.FindStringSubmatch(a.MimeType)
98 | if len(matches) != 2 {
99 | return a.MimeType
100 | }
101 | return matches[1]
102 | }
103 |
104 | type Archive struct {
105 | ID int64
106 | Artifact Artifact
107 | SavedAt sql.NullString
108 | }
109 |
--------------------------------------------------------------------------------
/types/types_test.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "fmt"
5 | "reflect"
6 | "testing"
7 | )
8 |
9 | func TestCleanerLinkParts(t *testing.T) {
10 | check := func(url string, expectedLeft string, expectedRight string) {
11 | left, right := CleanerLinkParts(url)
12 | if left != expectedLeft {
13 | t.Errorf("Wrong left part for `%s`: expected `%s`, got `%s`", url, expectedLeft, left)
14 | }
15 | if right != expectedRight {
16 | t.Errorf("Wrong right part for `%s`: expected `%s`, got `%s`", url, expectedRight, right)
17 | }
18 | }
19 |
20 | check("gopher://foo.bar/baz", "gopher://foo.bar", "/baz")
21 | check("https://example.com/", "example.com", "")
22 | check("http://xn--d1ahgkh6g.xn--90aczn5ei/%F0%9F%96%A4", "юникод.любовь", "/🖤")
23 | check("http://юникод.любовь/🖤", "юникод.любовь", "/🖤")
24 | check("http://example.com/?query=param#a/b", "example.com", "?query=param#a/b")
25 | check("mailto:user@example.com", "mailto:user@example.com", "")
26 | check("tel:+55551234567", "tel:+55551234567", "")
27 | }
28 |
29 | func TestGroupPostsByDate(t *testing.T) {
30 | tests := []struct {
31 | args []Bookmark
32 | wantGroupedPosts []LocalBookmarkGroup
33 | }{
34 | {
35 | []Bookmark{
36 | {
37 | CreationTime: "2024-01-10 15:35",
38 | Title: "I spilled energy drink on my MacBook keyboard.",
39 | },
40 | {
41 | CreationTime: "2024-01-10 15:37",
42 | Title: "Why did I even buy it? I don't drink energy drinks!",
43 | },
44 | {
45 | CreationTime: "2024-01-11 10:00",
46 | Title: "I ordered some compressed air.",
47 | },
48 | {
49 | CreationTime: "2024-01-12 12:45",
50 | Title: "I hope it will help me.",
51 | },
52 | },
53 | []LocalBookmarkGroup{
54 | {"2024-01-10", []Bookmark{
55 | {
56 | CreationTime: "2024-01-10 15:35",
57 | Title: "I spilled energy drink on my MacBook keyboard.",
58 | },
59 | {
60 | CreationTime: "2024-01-10 15:37",
61 | Title: "Why did I even buy it? I don't drink energy drinks!",
62 | },
63 | }},
64 | {"2024-01-11", []Bookmark{
65 | {
66 | CreationTime: "2024-01-11 10:00",
67 | Title: "I ordered some compressed air.",
68 | },
69 | }},
70 | {"2024-01-12", []Bookmark{
71 | {
72 | CreationTime: "2024-01-12 12:45",
73 | Title: "I hope it will help me.",
74 | },
75 | }},
76 | },
77 | },
78 | {
79 | nil, nil,
80 | },
81 | }
82 | for i, tt := range tests {
83 | t.Run(fmt.Sprintf("%d", i+1), func(t *testing.T) {
84 | if gotGroupedPosts := GroupLocalBookmarksByDate(tt.args); !reflect.DeepEqual(gotGroupedPosts, tt.wantGroupedPosts) {
85 | t.Errorf("GroupPostsByDate() = %v, want %v", gotGroupedPosts, tt.wantGroupedPosts)
86 | }
87 | })
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/web/bookmarklet.js:
--------------------------------------------------------------------------------
1 | // Save link bookmarklet for Betula
2 | // 2023 Umar Getagazov
3 | // Public domain, but attribution appreciated.
4 | // https://handlerug.me/betula-save-bookmarklet
5 |
6 | (($) => {
7 | function getSelectionInMycomarkup() {
8 | function convert(node, parentNodeName = '') {
9 | if (node instanceof Text) {
10 | if (node.textContent.trim() === '') {
11 | return '';
12 | }
13 |
14 | return node.textContent
15 | .replace(/\\/g, '\\\\')
16 | .replace(/\*\*/g, '\\**')
17 | .replace(/\/\//g, '\\//')
18 | .replace(/\+\+/g, '\\++');
19 | }
20 |
21 | let nodeName = node.nodeName.toLowerCase();
22 |
23 | let result = '';
24 | for (const child of node.childNodes) {
25 | result += convert(child, nodeName);
26 | }
27 |
28 | if (nodeName === 'p') {
29 | return `\n\n${result.trim()}\n\n`;
30 | } else if (nodeName === 'br') {
31 | return '\n';
32 | } else if (nodeName === 'a') {
33 | return `[[${decodeURI(node.href)} | ${result}]]`;
34 | } else if (nodeName === 'b' || nodeName === 'strong') {
35 | return `**${result}**`;
36 | } else if (nodeName === 'i' || nodeName === 'em') {
37 | return `//${result}//`;
38 | } else if (nodeName === 'h1') {
39 | return `\n\n${result}\n\n`;
40 | } else if (nodeName === 'h2') {
41 | return `= ${result}\n\n`;
42 | } else if (nodeName === 'h3') {
43 | return `== ${result}\n\n`;
44 | } else if (nodeName === 'h4') {
45 | return `=== ${result}\n\n`;
46 | } else if (nodeName === 'h5') {
47 | return `==== ${result}\n\n`;
48 | } else if (nodeName === 'li') {
49 | if (node.children.length === 1) {
50 | let link = node.children[0];
51 | if (link.nodeName.toLowerCase() === 'a') {
52 | if (link.href === link.innerText || decodeURI(link.href) === link.innerText) {
53 | return `=> ${decodeURI(link.href)}\n`;
54 | } else {
55 | return `=> ${decodeURI(link.href)} | ${link.innerText}\n`;
56 | }
57 | }
58 | }
59 | return parentNodeName === 'ol'
60 | ? `*. ${result}\n`
61 | : `* ${result}\n`;
62 | } else {
63 | return result;
64 | }
65 | }
66 |
67 | let selection = window.getSelection();
68 | if (selection.rangeCount === 0) {
69 | return '';
70 | }
71 | let range = selection.getRangeAt(0);
72 | let contents = range.cloneContents();
73 | return convert(contents).replace(/\n\n+/g, '\n\n');
74 | }
75 |
76 | let u = '%s/save-link?' + new URLSearchParams({
77 | url: ($('link[rel=canonical]') || location).href,
78 | title: $('meta[property="og:title"]')?.content || document.title,
79 | description: (
80 | getSelectionInMycomarkup() ||
81 | $('meta[property="og:description"]')?.content ||
82 | $('meta[name=description]')?.content
83 | )?.trim().replace(/^/gm, '> ') || ''
84 | });
85 |
86 | try {
87 | window.open(u, '_blank', 'location=yes,width=600,height=800,scrollbars=yes,status=yes,noopener,noreferrer');
88 | } catch {
89 | location.href = u;
90 | }
91 | })(document.querySelector.bind(document));
92 |
--------------------------------------------------------------------------------
/web/copytext.js:
--------------------------------------------------------------------------------
1 | async function copyTextElem(text, elem) {
2 | await navigator.clipboard.writeText(text)
3 | elem.textContent = "Copied!"
4 | }
5 |
--------------------------------------------------------------------------------
/web/error-template.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "git.sr.ht/~bouncepaw/betula/types"
5 | "net/http"
6 | )
7 |
8 | type errorTemplate interface {
9 | emptyUrl(post types.Bookmark, data *dataCommon, w http.ResponseWriter, rq *http.Request)
10 | invalidUrl(post types.Bookmark, data *dataCommon, w http.ResponseWriter, rq *http.Request)
11 | titleNotFound(post types.Bookmark, data *dataCommon, w http.ResponseWriter, rq *http.Request)
12 | }
13 |
14 | /* Error templates for edit link currentPage */
15 |
16 | func (d dataEditLink) emptyUrl(post types.Bookmark, data *dataCommon, w http.ResponseWriter, rq *http.Request) {
17 | w.WriteHeader(http.StatusBadRequest)
18 | templateExec(w, rq, templateEditLink, dataEditLink{
19 | Bookmark: post,
20 | dataCommon: data,
21 | ErrorEmptyURL: true,
22 | })
23 | }
24 |
25 | func (d dataEditLink) invalidUrl(post types.Bookmark, data *dataCommon, w http.ResponseWriter, rq *http.Request) {
26 | w.WriteHeader(http.StatusBadRequest)
27 | templateExec(w, rq, templateEditLink, dataEditLink{
28 | Bookmark: post,
29 | dataCommon: data,
30 | ErrorInvalidURL: true,
31 | })
32 | }
33 |
34 | func (d dataEditLink) titleNotFound(post types.Bookmark, data *dataCommon, w http.ResponseWriter, rq *http.Request) {
35 | w.WriteHeader(http.StatusInternalServerError)
36 | templateExec(w, rq, templateEditLink, dataEditLink{
37 | Bookmark: post,
38 | dataCommon: data,
39 | ErrorTitleNotFound: true,
40 | })
41 | }
42 |
43 | /* Error templates for save link currentPage */
44 |
45 | func (d dataSaveLink) emptyUrl(post types.Bookmark, data *dataCommon, w http.ResponseWriter, rq *http.Request) {
46 | w.WriteHeader(http.StatusBadRequest)
47 | templateExec(w, rq, templateSaveLink, dataSaveLink{
48 | Bookmark: post,
49 | dataCommon: data,
50 | ErrorEmptyURL: true,
51 | })
52 | }
53 |
54 | func (d dataSaveLink) invalidUrl(post types.Bookmark, data *dataCommon, w http.ResponseWriter, rq *http.Request) {
55 | w.WriteHeader(http.StatusBadRequest)
56 | templateExec(w, rq, templateSaveLink, dataSaveLink{
57 | Bookmark: post,
58 | dataCommon: data,
59 | ErrorInvalidURL: true,
60 | })
61 | }
62 |
63 | func (d dataSaveLink) titleNotFound(post types.Bookmark, data *dataCommon, w http.ResponseWriter, rq *http.Request) {
64 | w.WriteHeader(http.StatusInternalServerError)
65 | templateExec(w, rq, templateSaveLink, dataSaveLink{
66 | Bookmark: post,
67 | dataCommon: data,
68 | ErrorTitleNotFound: true,
69 | })
70 | }
71 |
--------------------------------------------------------------------------------
/web/pix/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bouncepaw/betula/7699c8b5d6af34b408988faf6cbd1e9c7dcaa39b/web/pix/favicon.png
--------------------------------------------------------------------------------
/web/pix/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
34 |
35 |
36 |
42 |
48 |
49 |
50 |
54 |
60 |
61 |
62 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/web/pix/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
8 |
9 |
11 |
12 |
14 |
15 |
--------------------------------------------------------------------------------
/web/views/about.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}About {{.SiteName}}{{end}}
2 | {{define "body"}}
3 |
4 |
5 | About {{.SiteName}}
6 | {{.SiteDescription}}
7 |
8 |
9 |
10 | This website runs Betula , a self-hosted bookmarking software.
11 | Host your own instance of Betula! See Betula's website and the the built-in documentation .
12 |
13 |
14 | Webmaster
15 | @{{.AdminUsername}}
16 | Federation
17 | {{if .FederationEnabled}}Enabled{{else}}Disabled{{end}}
18 | Betula version
19 | 1.4.0
20 |
21 |
22 |
23 | Licensing
24 | Betula is a free software, licensed under AGPLv3.
25 | If this instance is running the official version,
26 | then you may want to consult the following:
27 |
31 | If this instance is running a modified version,
32 | you are free to demand a copy of the software from the webmaster.
33 |
34 |
35 | {{end}}
--------------------------------------------------------------------------------
/web/views/bookmarklet.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Bookmarklet{{end}}
2 | {{define "body"}}
3 |
4 |
5 | Bookmarklet
6 |
7 | This special link allows you to add a link to your betula directly by using a bookmark in your web
8 | browser.
9 |
10 |
15 |
16 | Drag and drop this link to your bookmarks.
17 |
18 |
19 |
20 | {{end}}
21 |
--------------------------------------------------------------------------------
/web/views/day.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Day {{.DayStamp}}{{end}}
2 | {{define "body"}}
3 |
4 | {{$cnt := len .Bookmarks}}
5 | {{$cnt}} bookmark{{if ne $cnt 1}}s{{end}} for {{.DayStamp}}
6 |
7 | {{if .Authorized}}
8 | {{range .Bookmarks}}{{template "authorized bookmark card" .}}{{end}}
9 | {{else}}
10 | {{range .Bookmarks}}{{template "unauthorized bookmark card" .}}{{end}}
11 | {{end}}
12 |
13 | {{end}}
--------------------------------------------------------------------------------
/web/views/edit-link.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Edit link{{end}}
2 | {{define "body"}}
3 |
4 | {{if .RepostOf}}
5 |
6 | Edit repost
7 |
8 |
18 |
19 | {{else}}
20 |
21 | {{if .ErrorInvalidURL}}
22 | Invalid link
23 | The URL you have passed, {{.URL}}
, is invalid. Please enter a correct URL.
24 | {{else if .ErrorEmptyURL }}
25 | URL is not passed
26 | Please, provide a link.
27 | {{else if .ErrorTitleNotFound}}
28 | Title not found
29 | Please, provide a title yourself.
30 | {{else}}
31 | Edit bookmark
32 | {{end}}
33 |
37 |
38 | {{end}}
39 | {{if .RepostOf}}
40 |
41 | Unrepost
42 | You can make this reposted bookmark yours and edit it fully afterwards.
43 | The original bookmark author will be notified of the repost being removed.
44 |
52 |
53 | {{end}}
54 |
55 | {{if .RepostOf}}
56 | Delete repost
57 | The original bookmark owner will be notified of the repost being removed.
58 | {{else}}
59 | Delete link
60 | {{end}}
61 |
69 |
70 |
71 | {{end}}
72 |
--------------------------------------------------------------------------------
/web/views/edit-tag.gohtml:
--------------------------------------------------------------------------------
1 | {{define "form"}}
2 |
16 | {{end}}
17 |
18 | {{define "title"}}Edit tag {{.Name}}{{end}}
19 | {{define "body"}}
20 |
21 |
22 |
23 | {{if or .ErrorTakenName .ErrorNonExistent}}
24 | Invalid tag name
25 | {{end}}
26 | {{if .ErrorTakenName}}
27 |
28 | The tag already exists.
29 | Please enter another name.
30 |
31 | {{template "form" .}}
32 | {{else if .ErrorNonExistent}}
33 |
34 | The tag doesn't exist.
35 | Go to the tag list.
36 |
37 | {{else}}
38 | {{template "form" .}}
39 | {{end}}
40 |
41 |
42 | Delete tag
43 |
44 |
45 |
46 | Yes, delete this tag.
47 |
48 |
49 |
50 |
51 |
52 |
53 | {{end}}
--------------------------------------------------------------------------------
/web/views/fedisearch.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Fedisearch{{end}}
2 | {{define "body"}}{{$authed := .Authorized}}
3 |
4 |
5 | Federated search ⁂
6 | {{if eq .State nil}}
7 | Search bookmarks your mutuals have published.
8 | They can search your bookmarks similarly.
9 | Of course, only public bookmarks can be found.
10 |
{{end}}
11 | {{if len .Mutuals}}
12 |
13 |
14 | Federated search query
15 |
17 |
18 |
19 |
20 | {{else}}
21 |
22 | You have no mutuals now.
23 | Maybe follow someone who follows you ?
24 |
25 | {{end}}
26 |
27 |
28 | {{range .Bookmarks}}
29 |
30 |
35 |
36 |
37 | {{shortenLink .URL}}
38 |
39 | {{if .Description}}
40 |
41 | {{.Description}}
42 |
43 | {{end}}
44 |
45 | {{range $i, $cat := .Tags}}{{if $i}},{{end}}
{{$cat.Name}} {{end}}
46 |
47 |
48 | {{end}}
49 |
50 | {{if and .State .State.NextPageExpected}}
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | {{end}}
59 |
60 | {{if and (len .Mutuals) (eq .State nil)}}
61 |
62 | Mutuals
63 | These are the betulists that will be searched.
64 | {{range .Mutuals}}
65 | {{.Acct}}
66 | {{end}}
67 |
68 | {{end}}
69 |
70 | {{end}}
--------------------------------------------------------------------------------
/web/views/feed.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}{{.SiteName}}{{end}}
2 | {{define "body"}}
3 |
4 |
5 | {{.TotalBookmarks}} {{if .Random}}random{{end}} bookmark{{if ne .TotalBookmarks 1}}s{{end}}
6 | {{if eq .TotalBookmarks 0 | and .Authorized}}
7 |
8 | {{end}}
9 | {{if .SiteDescription}}{{.SiteDescription}}
{{end}}
10 |
11 | {{template "range bookmark groups + paginator" .}}
12 |
13 |
14 | {{end}}
--------------------------------------------------------------------------------
/web/views/followers.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Followers{{end}}
2 | {{define "body"}}
3 |
4 |
5 | Followers {{len .Actors}}
6 | See also Following .
7 |
8 | {{range .Actors}}
9 |
10 |
11 | {{.ID}}
12 |
13 | {{else}}
14 | Empty here...
15 | {{end}}
16 |
17 | {{end}}
18 |
--------------------------------------------------------------------------------
/web/views/following.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Following{{end}}
2 | {{define "body"}}
3 |
4 |
5 | Following {{len .Actors}}
6 | See also Followers .
7 |
8 | {{range .Actors}}
9 |
10 |
11 | {{.ID}}
12 |
13 | {{else}}
14 | Empty here...
15 | {{end}}
16 |
17 | {{end}}
18 |
--------------------------------------------------------------------------------
/web/views/help.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Help: {{.This.SidebarTitle}}{{end}}
2 | {{define "body"}}
3 |
4 |
5 | Betula documentation
6 | Choose a topic:
{{$this:=.This}}
7 |
8 | {{range .Topics}}
9 | {{if ne .Name $this.Name}}
10 |
11 | {{.SidebarTitle}}
12 |
13 | {{else}}
14 |
15 | {{.SidebarTitle}}
16 |
17 | {{end}}
18 | {{end}}
19 |
20 |
21 |
22 | {{.This.Rendered}}
23 |
24 |
25 | {{end}}
--------------------------------------------------------------------------------
/web/views/link-form-fragment.gohtml:
--------------------------------------------------------------------------------
1 | {{define "form fragment"}}
2 |
3 | URL
4 |
5 |
6 |
7 | Title
8 |
9 |
10 |
11 | Description, quotes
12 | {{.Description}}
13 |
14 |
15 | Who can see this bookmark?
16 |
17 | Everyone
18 |
19 |
20 | Only you
21 |
22 |
23 | Tags comma-separated
24 |
26 |
27 | {{end}}
28 |
--------------------------------------------------------------------------------
/web/views/login-form.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Log in{{end}}
2 | {{define "body"}}
3 |
4 |
5 | Log in
6 | {{if .Incorrect}}
7 | Incorrect data, try again.
8 | {{end}}
9 | {{if .Authorized}}
10 | You are already logged in.
11 | {{else}}
12 |
13 | Note that is impossible to register on this site.
14 | Only the site administrator can log in.
15 | Want to host your own Betula?
16 | See Betula's website for more information.
17 |
18 | {{end}}
19 |
20 |
21 | Username
22 |
23 |
24 |
25 | Password
26 |
27 |
28 | Betula will save a cookie on your device for authentication once you click the button below.
29 |
30 |
31 |
32 |
33 | {{end}}
--------------------------------------------------------------------------------
/web/views/logout-form.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Log out{{end}}
2 | {{define "body"}}
3 |
4 |
5 | Log out
6 |
7 | Yes, log me out:
8 |
9 |
10 |
11 |
12 | {{end}}
--------------------------------------------------------------------------------
/web/views/my-profile.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Profile{{end}}
2 | {{define "body"}}
3 |
4 |
5 |
6 |
7 |
8 |
9 | {{.SiteName}}
10 | {{if and .FederationEnabled}}
11 |
12 |
{{.Nickname}} Copy nickname
13 | {{if not .Authorized}}
14 |
Copy the nickname to subscribe from your Betula or other compatible software.
15 | {{end}}
16 |
17 | {{end}}
18 | {{.Summary}}
19 |
28 |
29 |
30 | Stats
31 |
32 | Bookmarks
33 | {{.LinkCount}}
34 | Tags
35 | {{.TagCount}}
36 | {{if and .FederationEnabled .Authorized}}
37 | Following
38 | {{.FollowingCount}}
39 | Followers
40 | {{.FollowersCount}}
41 | {{else if .FederationEnabled}}
42 | Following
43 | {{.FollowingCount}}
44 | Followers
45 | {{.FollowersCount}}
46 | {{end}}
47 | {{if .OldestTime}}
48 | Newest bookmark save time
49 | {{.NewestTime | timeToHuman}} UTC
50 | Oldest bookmark save time
51 | {{.OldestTime | timeToHuman}} UTC
52 | {{end}}
53 |
54 |
55 |
56 |
57 | {{end}}
58 |
--------------------------------------------------------------------------------
/web/views/paginator-fragment.gohtml:
--------------------------------------------------------------------------------
1 | {{define "paginator"}}
2 | {{if .MultiplePages}}
3 |
4 | {{range .Pages}}
5 | {{if .IsCurrent}}
{{.Number}}
6 | {{else}}
{{.Number}}
7 | {{end}}
8 | {{end}}
9 |
10 | {{end}}
11 | {{end}}
12 |
--------------------------------------------------------------------------------
/web/views/post-fragment.gohtml:
--------------------------------------------------------------------------------
1 | {{define "authorized bookmark card"}}
2 |
3 |
4 | {{if .RepostOf}}
Reposted {{end}}
5 |
{{.ID}}.
6 |
Edit
7 |
Copy link
8 | {{if .Visibility}}
9 |
Public
10 | {{else}}
11 |
Private
12 | {{end}}
13 |
14 |
15 |
16 | {{shortenLink .URL}}
17 |
18 | {{if .Description}}
19 |
20 | {{mycomarkup .Description}}
21 |
22 | {{end}}
23 |
24 | {{range $i, $cat := .Tags}}{{if $i}},{{end}}
{{$cat.Name}} {{end}}
25 |
26 |
27 | {{end}}
28 |
29 | {{/* Same as above, but no edit link and visibility marker */}}
30 | {{define "unauthorized bookmark card"}}
31 |
32 |
37 |
38 |
39 | {{shortenLink .URL}}
40 |
41 | {{if .Description}}
42 |
43 | {{mycomarkup .Description}}
44 |
45 | {{end}}
46 |
47 | {{range $i, $cat := .Tags}}{{if $i}},{{end}}
{{$cat.Name}} {{end}}
48 |
49 |
50 | {{end}}
51 |
52 | {{define "range bookmark groups + paginator"}}{{$authed := .Authorized}}
53 | {{- range .BookmarkGroupsInPage -}}
54 |
55 | {{- if $authed -}}
56 | {{range .Bookmarks}}{{template "authorized bookmark card" .}}{{end}}
57 | {{- else -}}
58 | {{range .Bookmarks}}{{template "unauthorized bookmark card" .}}{{end}}
59 | {{- end -}}
60 | {{- else -}}
61 | {{if ne .TotalBookmarks 0}}
62 | Page not found. Choose a page from the paginator below.
63 | {{end}}
64 | {{- end -}}
65 | {{template "paginator" .}}
66 | {{end}}
--------------------------------------------------------------------------------
/web/views/post.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Link: {{.Bookmark.Title}}{{end}}
2 | {{define "body"}}
3 |
4 | {{if .Notifications}}{{range .Notifications}}
5 | {{.Body}}
6 | {{end}}{{end}}
7 |
8 |
22 |
23 |
24 | {{shortenLink .Bookmark.URL}}
25 |
26 | {{if .Bookmark.Description}}
27 |
28 | {{mycomarkup .Bookmark.Description}}
29 |
30 | {{end}}
31 |
32 | {{range $i, $cat := .Bookmark.Tags}}{{if $i}},{{end}}
{{$cat.Name}} {{end}}
33 |
34 |
35 |
36 | {{timestampToHuman .Bookmark.CreationTime}} UTC
37 |
38 |
39 |
40 | {{if .Authorized}}
41 |
42 | Archive copies
43 |
44 |
45 |
46 | {{if len .Archives}}{{$bookmarkID := .Bookmark.ID}}
47 |
60 | {{end}}
61 |
62 | {{end}}
63 |
64 |
65 | {{end}}
--------------------------------------------------------------------------------
/web/views/register-form.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Welcome to Betula!{{end}}
2 | {{define "body"}}
3 |
4 |
5 | Welcome to Betula!
6 | If you see this page,
7 | Betula is successfully installed and working.
8 | Further configuration is required.
9 |
10 | Set up your account, so you can save links.
11 | Do not share these credentials,
12 | unless you really want to.
13 |
14 |
15 | Username
16 |
17 |
18 |
19 | Password
20 |
21 |
22 | Betula does not store your password. It only stores its hash.
23 |
24 |
25 | Thank you for using Betula.
26 |
27 |
28 | {{end}}
--------------------------------------------------------------------------------
/web/views/remote-profile.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}{{.Account.DisplayedName}}{{end}}
2 | {{define "body"}}
3 |
4 |
5 | {{.Account.DisplayedName}}
6 |
7 | {{.Account.Summary}}
8 | {{$status := .Account.SubscriptionStatus}}
9 | {{if eq $status "mutual"}}
10 |
11 | You are mutuals!
12 |
13 |
14 | {{else if eq $status "following"}}
15 |
16 | Following
17 |
18 |
19 | {{else if eq $status "follower"}}
20 |
21 | They follow you.
22 |
23 |
24 | {{else if eq $status "pending"}}
25 |
26 | Follow pending.
27 |
28 |
29 |
30 |
31 |
32 | {{else if eq $status "pending mutual"}}
33 |
34 | They follow you. Subscription pending.
35 |
36 |
37 |
38 |
39 |
40 | {{else}}
41 |
42 |
43 |
44 | {{end}}
45 |
46 | {{if eq .TotalBookmarks 0}}
47 | No bookmarks were sent to us from them yet.
48 | {{else}}
49 | {{template "remote bookmarks paginated" .}}
50 | {{end}}
51 |
52 | {{end}}
53 |
--------------------------------------------------------------------------------
/web/views/repost.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Repost{{end}}
2 | {{define "body"}}
3 |
4 |
5 | {{if .ErrorEmptyURL}}
6 | URL is not passed
7 | Please, provide a link.
8 | {{else if .ErrorInvalidURL}}
9 | Invalid URL
10 | The link you have passed, {{.URL}}
, is invalid. Please enter a correct URL.
11 | {{else if .ErrorImpossible}}
12 | Repost impossible
13 | The page you entered does not support Betula's reposts. Is it running Betula? Is it the right page?
14 | Do you want to save this link instead? Saving never fails.
15 | {{else if .ErrorTimeout}}
16 | Server timed out
17 | The page takes too long to load, cannot repost now.
18 |
19 | Do you want to save this link instead? Saving never fails.
20 | {{else if .Err}}
21 | Error
22 | Error message: {{.Err.Error}}.
23 | {{else}}
24 | Repost (experimental)
25 | Share bookmark from other Betulæ and compatible software quickly. Reposts link back the original bookmarks. You can edit the reposts' data freely.
26 | {{end}}
27 |
28 |
29 | URL
30 |
31 |
32 |
33 |
34 | Who can see the repost?
35 |
36 | Everyone
37 |
38 |
39 | Only you
40 |
41 |
42 |
43 |
44 | Copy their tags
45 |
46 |
47 |
48 |
49 |
50 |
51 | {{end}}
52 |
--------------------------------------------------------------------------------
/web/views/reposts-of.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Reposts of {{.ID}}{{end}}
2 | {{define "body"}}
3 |
4 |
5 |
6 | {{if .Reposts}}
7 |
8 | {{range .Reposts}}
9 | {{.Name}} reposted at {{.Timestamp | timeToHuman}}
10 | {{end}}
11 |
12 | {{else}}
13 | This bookmark was not reposted before, or you were not told about any reposts.
14 | {{end}}
15 | Want to repost? Paste the URL of the bookmark on the Repost page in your Betula.
16 |
17 |
18 | {{end}}
--------------------------------------------------------------------------------
/web/views/save-link.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Save link{{end}}
2 | {{define "body"}}
3 |
4 |
5 | {{if .ErrorInvalidURL}}
6 | Invalid link
7 | The URL you have passed, {{.URL}}
, is invalid. Please enter a correct URL.
8 | {{else if .ErrorEmptyURL}}
9 | URL is not passed
10 | Please, provide a link.
11 | {{else if .ErrorTitleNotFound}}
12 | Title not found
13 | Please, provide a title yourself.
14 | {{else}}
15 | Save link
16 | {{end}}
17 |
18 | {{template "form fragment" .}}
19 |
20 |
21 |
22 | Submit another?
23 |
24 |
25 |
26 |
27 | {{end}}
28 |
--------------------------------------------------------------------------------
/web/views/search.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Search: {{.Query}}{{end}}
2 | {{define "body"}}{{$authed := .Authorized}}
3 |
4 |
5 | Search: {{.Query}}
6 | {{.TotalBookmarks}} bookmark{{if ne .TotalBookmarks 1}}s{{end}} match the query.
7 | {{if .FederationEnabled}}
8 |
9 |
10 |
11 |
12 | {{end}}
13 |
14 | {{template "range bookmark groups + paginator" .}}
15 |
16 |
17 | {{end}}
--------------------------------------------------------------------------------
/web/views/sessions.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Sessions{{end}}
2 | {{define "body"}}
3 |
4 |
5 | Sessions
6 | {{if .Sessions}}
7 |
8 | {{range .Sessions}}
9 |
10 |
11 | {{if .Current}}Current session:{{end}}
12 | {{.UserAgent.Name}} on {{.UserAgent.OS}} {{.LastSeen}}
13 | {{if .Current}}
14 | Copy token
15 | {{end}}
16 |
17 |
18 |
19 |
24 |
25 |
26 |
27 | {{end}}
28 |
29 |
30 | {{ $length := len .Sessions }}
31 | {{ if gt $length 1 }}
32 |
33 |
34 |
35 |
37 | Delete all sessions, but the current one.
38 |
39 |
40 |
41 |
42 | {{end}}
43 | {{else}}
44 | No active sessions.
45 | {{end}}
46 |
47 |
48 | {{end}}
49 |
--------------------------------------------------------------------------------
/web/views/settings.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Settings{{end}}
2 | {{define "body"}}
3 |
4 |
5 | Settings
6 | {{if .FirstRun}}
7 | Set up your Betula. You can always revisit these settings later from your profile.
8 | {{end}}
9 |
10 | Basic
11 |
12 |
13 |
14 |
Site name
15 |
16 |
The name of your site.
17 |
18 |
19 |
20 |
Site address
21 |
22 |
23 | The address at which your Betula is hosted.
24 | Type out the protocol (http or https).
25 | This information is used for RSS feed, bookmarklet generation and reposts.
26 |
27 |
28 |
29 |
Site title
30 |
{{.SiteTitle}}
31 |
32 | Displayed on the top of every page in h1.
33 | HTML supported. If left empty, defaults to site name.
34 |
35 |
36 |
37 |
Site description
38 |
{{.SiteDescriptionMycomarkup}}
39 |
40 | Formatted in Mycomarkup.
41 | Shown on the Bookmarks and About pages.
42 |
43 |
44 |
45 |
46 |
47 |
Enable federation (Fediverse)
48 |
With enabled federation, you can subscribe to other federated Betulæ,
49 | they can subscribe to you, and reposts are fully functional.
50 | Federation works only if you have the domain name set up properly.
51 |
52 |
53 |
54 |
55 | Advanced
56 |
57 |
58 |
⚠️ Network address
59 |
60 |
61 | The URL you are using currently will probably stop working.
62 | Betula will start working on the new hostname after saving settings.
63 | Make sure you know what you are doing.
64 |
65 | Leave empty to listen on all interfaces.
66 | You can also use a domain name.
67 |
It will not affect the ‘Site address’ setting.
68 |
⚠️ Port
69 | {{if .ErrBadPort}}
70 |
Invalid port value was passed. Choose a number between 1 and 65535.
71 | {{end}}
72 |
73 |
74 | Choose a positive number, preferably bigger than 1024.
75 | Default port is 1738.
76 | Make sure to not conflict with other services.
77 |
78 | The URL you are using currently will probably stop working.
79 | Betula will start working on the new port after saving settings.
80 | Make sure you know what you are doing.
81 |
82 |
83 |
84 |
Custom CSS
85 |
{{.CustomCSS}}
86 |
87 | This stylesheet will be served right after the original Betula stylesheet.
88 |
89 |
90 |
91 |
92 |
Public custom JavaScript
93 |
{{.PublicCustomJS}}
94 |
95 | This script will be loaded for everyone.
96 |
97 |
98 |
99 |
100 |
Private custom JavaScript
101 |
{{.PrivateCustomJS}}
102 |
103 | This script will be loaded only for you, after the public script.
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 | {{end}}
112 |
--------------------------------------------------------------------------------
/web/views/skeleton.gohtml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{template "title" .}}
5 |
6 |
7 |
8 |
9 |
10 | {{.Head}}
11 |
12 |
13 |
27 | {{if .SystemNotifications}}
28 |
29 | {{range .SystemNotifications}}
30 |
{{.Body}}
31 | {{end}}
32 |
33 | {{end}}
34 |
35 |
36 |
37 |
38 |
46 |
47 | {{template "body" .}}
48 | {{if .PublicCustomJS}}{{end}}
49 | {{if and .Authorized .PrivateCustomJS}}{{end}}
50 |
51 |
52 |
--------------------------------------------------------------------------------
/web/views/status.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}{{.Status}}{{end}}
2 | {{define "body"}}
3 |
4 |
5 | {{.Status}}
6 | The page you requested for is not there.
7 |
8 |
9 | It may have not been there to begin with.
10 |
11 |
12 | Maybe, they lied to you by giving the wrong URL.
13 |
14 |
15 | Maybe, the page was deleted or moved.
16 |
17 |
18 | Anyway, feel free to surf around here.
19 |
20 |
21 | {{end}}
--------------------------------------------------------------------------------
/web/views/tag.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Tag {{.Name}}{{end}}
2 | {{define "body"}}{{$authed := .Authorized}}
3 |
4 |
5 | {{if $authed}}
6 |
7 | Edit
8 |
9 | {{end}}
10 | Tag {{.Name}}
11 | {{.TotalBookmarks}} bookmark{{if ne .TotalBookmarks 1}}s have{{else}} has{{end}} this tag.
12 | {{.Description | mycomarkup}}
13 | {{if .FederationEnabled}}
14 |
15 |
16 |
17 |
18 | {{end}}
19 |
20 | {{template "range bookmark groups + paginator" .}}
21 |
22 |
23 | {{end}}
--------------------------------------------------------------------------------
/web/views/tags.gohtml:
--------------------------------------------------------------------------------
1 | {{define "title"}}Tags{{end}}
2 | {{define "body"}}
3 |
4 |
5 | Tags
6 | {{if .Tags}}
7 |
8 | {{range .Tags}}
9 |
10 | {{.Name}}
11 | — {{.BookmarkCount}}
12 |
13 | {{end}}
14 |
15 | {{else}}
16 | No tags.
17 | {{end}}
18 |
19 |
20 | {{end}}
21 |
--------------------------------------------------------------------------------
/web/views/timeline.gohtml:
--------------------------------------------------------------------------------
1 | {{define "remote bookmarks paginated"}}
2 | {{range .BookmarkGroupsInPage}}
3 | {{.Date}}
4 | {{range .Bookmarks}}
5 |
6 |
11 |
12 |
13 | {{shortenLink .URL}}
14 |
15 | {{if .Description}}
16 |
17 | {{.Description}}
18 |
19 | {{end}}
20 |
21 | {{range $i, $cat := .Tags}}{{if $i}},{{end}}
{{$cat.Name}} {{end}}
22 |
23 |
24 | {{end}}
25 | {{else}}
26 | {{if ne .TotalBookmarks 0}}
27 | Page not found. Choose a page from the paginator below.
28 | {{else}}
29 | Nothing yet.
30 | {{end}}
31 | {{end}}
32 | {{template "paginator" .}}
33 | {{end}}
34 |
35 | {{define "title"}}Timeline{{end}}
36 | {{define "body"}}
37 |
38 |
39 | Timeline
40 | New bookmarks from people you follow. To follow somebody,
41 | open their profile by inserting their username (looks like @username@example.org)
42 | in the search bar.
43 | {{if .Following}}
44 | Following {{.Following}} people .
45 | {{else}}
46 | You are not following anybody now.
47 | {{end}}
48 |
49 | {{template "remote bookmarks paginated" .}}
50 |
51 |
52 | {{end}}
--------------------------------------------------------------------------------
/web/web.go:
--------------------------------------------------------------------------------
1 | // Package web provides web capabilities. Import this package to initialize the handlers and the templates.
2 | package web
3 |
4 | import (
5 | "errors"
6 | "fmt"
7 | "git.sr.ht/~bouncepaw/betula/db"
8 | "git.sr.ht/~bouncepaw/betula/types"
9 | "log"
10 | "log/slog"
11 | "net/http"
12 | "strconv"
13 | "strings"
14 |
15 | "git.sr.ht/~bouncepaw/betula/auth"
16 | "git.sr.ht/~bouncepaw/betula/settings"
17 | )
18 |
19 | var serverRestartChannel = make(chan struct{})
20 |
21 | func StartServer() {
22 | go restartServer()
23 | var srv = &http.Server{}
24 | for range serverRestartChannel {
25 | if err := srv.Close(); err != nil {
26 | // Is it important? Does it matter?
27 | log.Println("Closing server:", err)
28 | }
29 | srv = &http.Server{
30 | Addr: listenAddr(),
31 | Handler: &auther{mux},
32 | }
33 | slog.Info("Running HTTP server", "addr", srv.Addr)
34 | go func() {
35 | if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
36 | log.Fatalln(err)
37 | }
38 | }()
39 | }
40 | }
41 |
42 | func listenAddr() string {
43 | return fmt.Sprintf("%s:%d", settings.NetworkHost(), settings.NetworkPort())
44 | }
45 |
46 | func restartServer() {
47 | serverRestartChannel <- struct{}{}
48 | }
49 |
50 | type auther struct {
51 | http.Handler
52 | }
53 |
54 | type dataAuthorized struct {
55 | *dataCommon
56 | Status string
57 | }
58 |
59 | func (a *auther) ServeHTTP(w http.ResponseWriter, rq *http.Request) {
60 | // Auth is OK if it is set up or the user wants to set it up or they request static data.
61 | authOK := auth.Ready() ||
62 | strings.HasPrefix(rq.URL.Path, "/static/") ||
63 | strings.HasPrefix(rq.URL.Path, "/register")
64 |
65 | // We don't support anything else.
66 | // A thought for a future Bouncepaw: maybe we should support HEAD?
67 | allowedMethod := rq.Method == http.MethodGet || rq.Method == http.MethodPost
68 |
69 | if !allowedMethod {
70 | w.WriteHeader(http.StatusBadRequest)
71 | _, _ = w.Write([]byte(
72 | fmt.Sprintf("Method %s is not supported by this server. Use POST and GET.", rq.Method)))
73 | return
74 | }
75 |
76 | if !authOK {
77 | templateExec(w, rq, templateRegisterForm, dataAuthorized{
78 | dataCommon: emptyCommon(),
79 | })
80 | return
81 | }
82 |
83 | a.Handler.ServeHTTP(w, rq)
84 | }
85 |
86 | func extractPage(rq *http.Request) (currentPage uint) {
87 | if page, err := strconv.Atoi(rq.FormValue("page")); err != nil || page == 0 {
88 | currentPage = 1
89 | } else {
90 | currentPage = uint(page)
91 | }
92 | return
93 | }
94 |
95 | func extractBookmark(w http.ResponseWriter, rq *http.Request) (*types.Bookmark, bool) {
96 | id, ok := extractBookmarkID(w, rq)
97 | if !ok {
98 | return nil, false
99 | }
100 |
101 | bookmark, found := db.GetBookmarkByID(id)
102 | if !found {
103 | log.Printf("%s: bookmark no. %d not found\n", rq.URL.Path, id)
104 | handlerNotFound(w, rq)
105 | return nil, false
106 | }
107 |
108 | authed := auth.AuthorizedFromRequest(rq)
109 | if bookmark.Visibility == types.Private && !authed {
110 | log.Printf("Unauthorized attempt to access %s. %d.\n", rq.URL.Path, http.StatusUnauthorized)
111 | handlerUnauthorized(w, rq)
112 | return nil, false
113 | }
114 |
115 | bookmark.Tags = db.TagsForBookmarkByID(bookmark.ID)
116 |
117 | return &bookmark, true
118 | }
119 |
120 | // returns id, found
121 | func extractBookmarkID(w http.ResponseWriter, rq *http.Request) (int, bool) {
122 | id, err := strconv.Atoi(rq.PathValue("id"))
123 | if err != nil {
124 | log.Printf("Extracting bookmark no. from %s: wrong format\n", rq.URL.Path)
125 | handlerNotFound(w, rq)
126 | return 0, false
127 | }
128 | return id, true
129 | }
130 |
131 | // Wrap handlers that only make sense for the admin with this thingy in init().
132 | func adminOnly(next func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
133 | return func(w http.ResponseWriter, rq *http.Request) {
134 | authed := auth.AuthorizedFromRequest(rq)
135 | if !authed {
136 | log.Printf("Unauthorized attempt to access %s. %d.\n", rq.URL.Path, http.StatusUnauthorized)
137 | handlerUnauthorized(w, rq)
138 | return
139 | }
140 | next(w, rq)
141 | }
142 | }
143 |
144 | func federatedOnly(next func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
145 | return func(w http.ResponseWriter, rq *http.Request) {
146 | federated := settings.FederationEnabled()
147 | if !federated {
148 | log.Printf("Attempt to access %s failed because Betula is not federated. %d.\n", rq.URL.Path, http.StatusUnauthorized)
149 | handlerNotFederated(w, rq)
150 | return
151 | }
152 | next(w, rq)
153 | }
154 | }
155 |
156 | func fediverseWebFork(
157 | nextFedi func(http.ResponseWriter, *http.Request),
158 | nextWeb func(http.ResponseWriter, *http.Request),
159 | ) func(http.ResponseWriter, *http.Request) {
160 | return func(w http.ResponseWriter, rq *http.Request) {
161 | if strings.HasPrefix(rq.URL.Path, "/@") {
162 | handlerAt(w, rq)
163 | return
164 | }
165 | wantsActivity := strings.Contains(rq.Header.Get("Accept"), types.ActivityType) || strings.Contains(rq.Header.Get("Accept"), types.OtherActivityType)
166 | if wantsActivity && nextFedi != nil {
167 | federatedOnly(nextFedi)(w, rq)
168 | } else if nextWeb != nil {
169 | nextWeb(w, rq)
170 | } else {
171 | handlerNotFound(w, rq)
172 | }
173 | }
174 | }
175 |
--------------------------------------------------------------------------------