├── .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 | ![A screenshot of Betula, featuring several bookmarks](https://betula.mycorrhiza.wiki/betula-v1.4.0.png) 3 | 4 | [![Hits-of-Code](https://hitsofcode.com/sourcehut/~bouncepaw/betula?branch=master)](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 |

%s

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 | 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 | Betula logo 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 |
9 |
10 | 11 | 13 |

You can only edit tags of reposts.

14 | 15 |
16 | 17 |
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 |
34 | {{template "form fragment" .}} 35 | 36 |
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 |
45 |
46 | 47 | 48 |
49 | 50 |
51 |
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 |
62 |
63 | 64 | 65 |
66 | 67 |
68 |
69 |
70 |
71 | {{end}} 72 | -------------------------------------------------------------------------------- /web/views/edit-tag.gohtml: -------------------------------------------------------------------------------- 1 | {{define "form"}} 2 |
3 |
4 | 5 | 6 | 7 | 8 |
9 |
10 | 11 | 12 |

Formatted in Mycomarkup

13 |
14 | 15 |
16 | {{end}} 17 | 18 | {{define "title"}}Edit tag {{.Name}}{{end}} 19 | {{define "body"}} 20 |
21 |
22 |

Edit tag {{.Name}}

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 | 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 | 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 | 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 |
Save link
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 | 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 | 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 | 4 | 5 |
6 |
7 | 8 | 9 |
10 |
11 | 12 | 13 |
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | 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 | 22 | 23 |

24 |

25 | 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 | Betula logo 8 |
9 |

{{.SiteName}}

10 | {{if and .FederationEnabled}} 11 |
12 |

{{.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 |
20 | About instance 21 | RSS 22 | 23 | {{if .Authorized}} 24 | Sessions 25 | Bookmarklet 26 | Log out{{end}} 27 |
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}} 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 | 8 | {{if .Visibility}} 9 | Public 10 | {{else}} 11 | Private 12 | {{end}} 13 |
14 | 15 |

{{.Title}}

16 | {{shortenLink .URL}} 17 | 18 | {{if .Description}} 19 |
20 | {{mycomarkup .Description}} 21 |
22 | {{end}} 23 | 26 |
27 | {{end}} 28 | 29 | {{/* Same as above, but no edit link and visibility marker */}} 30 | {{define "unauthorized bookmark card"}} 31 |
32 |
33 | {{if .RepostOf}}Reposted{{end}} 34 | {{.ID}}. 35 | 36 |
37 | 38 |

{{.Title}}

39 | {{shortenLink .URL}} 40 | 41 | {{if .Description}} 42 |
43 | {{mycomarkup .Description}} 44 |
45 | {{end}} 46 | 49 |
50 | {{end}} 51 | 52 | {{define "range bookmark groups + paginator"}}{{$authed := .Authorized}} 53 | {{- range .BookmarkGroupsInPage -}} 54 |

{{.Date}}

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 |
9 | {{if .Bookmark.RepostOf}}Reposted{{end}} 10 | {{if .RepostCount}}{{.RepostCount}} repost{{if gt .RepostCount 1}}s{{end}}{{end}} 11 | {{.Bookmark.ID}}. 12 | {{if .Authorized}}Edit{{end}} 13 | 14 | {{if .Authorized}} 15 | {{if .Bookmark.Visibility}} 16 | Public 17 | {{else}} 18 | Private 19 | {{end}} 20 | {{end}} 21 |
22 | 23 |

{{.Bookmark.Title}}

24 | {{shortenLink .Bookmark.URL}} 25 | 26 | {{if .Bookmark.Description}} 27 |
28 | {{mycomarkup .Bookmark.Description}} 29 |
30 | {{end}} 31 | 34 | 35 | 38 | 39 |
40 | {{if .Authorized}} 41 | 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 | 16 | 17 |

18 |

19 | 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 |
{{.Account.Acct}}
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 | 30 | 31 |
32 | 33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 | 42 |
43 | 44 | 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 |

Reposts of {{.Title}}

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 | 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 | 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 | 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 | 15 | 16 |

    The name of your site.

    17 |
    18 | 19 |
    20 | 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 | 30 | 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 | 38 | 39 |

    40 | Formatted in Mycomarkup. 41 | Shown on the Bookmarks and About pages. 42 |

    43 |
    44 | 45 |
    46 | 47 | 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 | 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 | 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 | 85 | 86 |

    87 | This stylesheet will be served right after the original Betula stylesheet. 88 |

    89 |
    90 | 91 |
    92 | 93 | 94 |

    95 | This script will be loaded for everyone. 96 |

    97 |
    98 | 99 |
    100 | 101 | 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 |
    14 | 26 |
    27 | {{if .SystemNotifications}} 28 |
    29 | {{range .SystemNotifications}} 30 |
    {{.Body}}
    31 | {{end}} 32 |
    33 | {{end}} 34 | 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 |
    1. 10 | {{.Name}} 11 | — {{.BookmarkCount}} 12 |
    2. 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 |
    7 | {{.AuthorDisplayedName}} {{if .RepostOf.Valid}}reposted{{else}}bookmarked{{end}} 8 | 9 | Repost 10 |
    11 | 12 |

    {{.Title}}

    13 | {{shortenLink .URL}} 14 | 15 | {{if .Description}} 16 |
    17 | {{.Description}} 18 |
    19 | {{end}} 20 | 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 | --------------------------------------------------------------------------------