├── .gitignore ├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── migrations ├── README.md ├── 015_received.go ├── 029_avatar.go ├── 012_fetched.go ├── 022_actorttl.go ├── 050_didhost.go ├── 030_noimage.go ├── 037_publickey.go ├── 042_quote.go ├── 013_move.go ├── 018_notesfts.go ├── 005_edits.go ├── 001_notesupdated.go ├── 006_outboxactor.go ├── 003_activitiesid.go ├── 010_sharedinbox.go ├── 002_personspreferredusername.go ├── 019_iconsname.go ├── 033_shareactivity.go ├── 040_autodel.go ├── 034_application.go ├── 016_namehost.go ├── 057_localkeys.go ├── 007_outboxsender.go ├── 025_certhash.go ├── 008_thread.go ├── 009_host.go ├── 017_outboxhost.go ├── 011_noteshost.go ├── 038_resolvegroup.go ├── 026_follows_sync.go ├── 014_cleanup.go ├── 020_nohash.go ├── 032_bookmarks.go ├── 052_invites.go ├── 028_localforward.go ├── 048_deliverieshost.go ├── 024_followeds.go ├── list.sh ├── add.sh ├── 031_feed.go ├── 036_rawforward.go ├── 021_shares.go ├── 051_rsapkcs8.go ├── 056_actor.go ├── 035_certificates.go ├── 039_reject.go ├── 054_rsablob.go ├── 053_ed25519blob.go ├── 023_tocc.go ├── 044_keys.go ├── 049_pembegin.go ├── 055_iconscid.go └── 004_outbox.go ├── front ├── static │ ├── users │ │ ├── post.gmi │ │ └── settings.gmi │ ├── help.gmi │ └── embed.go ├── user │ ├── user.go │ └── app.go ├── text │ ├── plain │ │ └── plain.go │ ├── text.go │ ├── writer.go │ └── wrap.go ├── front.go ├── robots.go ├── oops.go ├── me.go ├── home.go ├── unbookmark.go ├── users.go ├── request.go ├── search.go ├── dm.go ├── whisper.go ├── say.go ├── mentions.go ├── unfollow.go ├── approve.go ├── hashtag.go ├── unshare.go ├── delete.go ├── local.go ├── revoke.go ├── reject.go ├── resolve.go ├── accept.go ├── menu.go ├── certificates.go ├── logo.go ├── export.go ├── bookmarks.go ├── alias.go └── communities.go ├── go.mod ├── httpsig ├── httpsig.go ├── key.go ├── string.go └── sign.go ├── danger ├── danger.go ├── bytes.go ├── string.go └── json.go ├── fed ├── fed.go ├── client.go ├── no_pprof.go ├── robots.go ├── redirect.go ├── hostmeta.go ├── index.go ├── post.go ├── pprof.go ├── user.go ├── activity.go ├── icon.go └── blocklist_test.go ├── data ├── data.go ├── id.go └── key_test.go ├── ap ├── public.go ├── public_key.go ├── poll_option.go ├── ap.go ├── key.go ├── generator.go ├── tag.go ├── policy.go ├── proof.go ├── capability.go ├── time.go ├── array.go ├── id_test.go ├── resolver.go ├── attachment.go ├── inbox.go ├── audience.go └── audience_test.go ├── icon ├── icon.go ├── scale.go └── generate.go ├── outbox └── outbox.go ├── cluster ├── line.go ├── name_test.go ├── bio_test.go ├── client.go ├── quote_test.go ├── cluster.go └── share_test.go ├── lock └── lock.go ├── test ├── home_test.go ├── static_test.go ├── status_test.go ├── communities_test.go ├── say_test.go ├── name_test.go └── search_test.go ├── inbox ├── id.go ├── move.go ├── undo.go ├── reject.go ├── accept.go └── announce.go └── Dockerfile /.gitignore: -------------------------------------------------------------------------------- 1 | /migrations/migrations.go 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.git* 2 | /README.md 3 | /Dockerfile 4 | /.dockerignore 5 | /migrations/add.sh 6 | /migrations/migrations.go 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: / 5 | schedule: 6 | interval: daily 7 | -------------------------------------------------------------------------------- /migrations/README.md: -------------------------------------------------------------------------------- 1 | # Migrations 2 | 3 | To add a migration named `x` and add it to the list of migrations: 4 | 5 | ./migrations/add.sh x 6 | go generate ./migrations 7 | -------------------------------------------------------------------------------- /migrations/015_received.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func received(ctx context.Context, domain string, tx *sql.Tx) error { 9 | _, err := tx.ExecContext(ctx, `ALTER TABLE outbox ADD COLUMN received TEXT`) 10 | return err 11 | } 12 | -------------------------------------------------------------------------------- /migrations/029_avatar.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func avatar(ctx context.Context, domain string, tx *sql.Tx) error { 9 | _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX iconsname ON icons(name)`) 10 | return err 11 | } 12 | -------------------------------------------------------------------------------- /migrations/012_fetched.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func fetched(ctx context.Context, domain string, tx *sql.Tx) error { 9 | _, err := tx.ExecContext(ctx, `ALTER TABLE persons ADD COLUMN fetched INTEGER`) 10 | return err 11 | } 12 | -------------------------------------------------------------------------------- /migrations/022_actorttl.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func actorttl(ctx context.Context, domain string, tx *sql.Tx) error { 9 | _, err := tx.ExecContext(ctx, `CREATE INDEX personsupdated ON persons(updated)`) 10 | return err 11 | } 12 | -------------------------------------------------------------------------------- /migrations/050_didhost.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func didhost(ctx context.Context, domain string, tx *sql.Tx) error { 9 | _, err := tx.ExecContext(ctx, `DELETE FROM servers WHERE host LIKE 'did:key:%'`) 10 | return err 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * 4' 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | permissions: {} 13 | uses: ./.github/workflows/build.yml 14 | with: 15 | version: v99.99.${{ github.run_number }} 16 | -------------------------------------------------------------------------------- /migrations/030_noimage.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func noimage(ctx context.Context, domain string, tx *sql.Tx) error { 9 | _, err := tx.ExecContext(ctx, `UPDATE persons SET actor = json_remove(actor, '$.image') WHERE host = ?`, domain) 10 | return err 11 | } 12 | -------------------------------------------------------------------------------- /migrations/037_publickey.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func publickey(ctx context.Context, domain string, tx *sql.Tx) error { 9 | _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX personspublickeyid ON persons(actor->>'$.publicKey.id')`) 10 | return err 11 | } 12 | -------------------------------------------------------------------------------- /migrations/042_quote.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func quote(ctx context.Context, domain string, tx *sql.Tx) error { 9 | _, err := tx.ExecContext(ctx, `CREATE INDEX notesquote ON notes(object->>'$.quote') WHERE object->>'$.quote' IS NOT NULL`) 10 | return err 11 | } 12 | -------------------------------------------------------------------------------- /migrations/013_move.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func move(ctx context.Context, domain string, tx *sql.Tx) error { 9 | _, err := tx.ExecContext(ctx, `CREATE INDEX personsmovedto ON persons(actor->>'movedTo') WHERE actor->>'movedTo' IS NOT NULL`) 10 | return err 11 | } 12 | -------------------------------------------------------------------------------- /migrations/018_notesfts.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func notesfts(ctx context.Context, domain string, tx *sql.Tx) error { 9 | _, err := tx.ExecContext(ctx, `CREATE VIRTUAL TABLE notesfts USING fts5(id UNINDEXED, content, tokenize = "unicode61 tokenchars '#@'")`) 10 | return err 11 | } 12 | -------------------------------------------------------------------------------- /migrations/005_edits.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func edits(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `CREATE INDEX outboxobjectid ON outbox(activity->>'object.id')`); err != nil { 10 | return err 11 | } 12 | 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /migrations/001_notesupdated.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func notesupdated(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `ALTER TABLE notes ADD COLUMN updated INTEGER DEFAULT 0`); err != nil { 10 | return err 11 | } 12 | 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /migrations/006_outboxactor.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func outboxactor(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `CREATE INDEX outboxactor ON outbox(activity->>'actor')`); err != nil { 10 | return err 11 | } 12 | 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /migrations/003_activitiesid.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func activitiesid(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX activitiesid ON activities(activity->>'id')`); err != nil { 10 | return err 11 | } 12 | 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /migrations/010_sharedinbox.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | ) 8 | 9 | func sharedinbox(ctx context.Context, domain string, tx *sql.Tx) error { 10 | _, err := tx.ExecContext(ctx, `UPDATE persons SET actor = json_set(actor, '$.endpoints.sharedInbox', $1) WHERE host = $2`, fmt.Sprintf("https://%s/inbox/nobody", domain), domain) 11 | return err 12 | } 13 | -------------------------------------------------------------------------------- /migrations/002_personspreferredusername.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func personspreferredusername(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS personspreferredusername ON persons(actor->>'preferredUsername')`); err != nil { 10 | return err 11 | } 12 | 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /front/static/users/post.gmi: -------------------------------------------------------------------------------- 1 | # New Post 2 | 3 | Who should be able to see your new post? 4 | 5 | => /users/dm 💌 Mentioned users only 6 | => titan://{{.Domain}}/users/upload/dm Upload text file 7 | 8 | => /users/whisper 🔔 Your followers and mentioned users 9 | => titan://{{.Domain}}/users/upload/whisper Upload text file 10 | 11 | => /users/say 📣 Anyone 12 | => titan://{{.Domain}}/users/upload/say Upload text file 13 | -------------------------------------------------------------------------------- /migrations/019_iconsname.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func iconsname(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `DROP TABLE icons`); err != nil { 10 | return err 11 | } 12 | 13 | _, err := tx.ExecContext(ctx, `CREATE TABLE icons(name STRING NOT NULL PRIMARY KEY, buf BLOB NOT NULL)`) 14 | return err 15 | } 16 | -------------------------------------------------------------------------------- /migrations/033_shareactivity.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func shareactivity(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `ALTER TABLE shares ADD COLUMN activity TEXT`); err != nil { 10 | return err 11 | } 12 | 13 | _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX sharesactivity ON shares(activity)`) 14 | return err 15 | } 16 | -------------------------------------------------------------------------------- /migrations/040_autodel.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func autodel(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ADD COLUMN ttl INTEGER`); err != nil { 10 | return err 11 | } 12 | 13 | _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX personsidttl ON persons(id) WHERE ttl IS NOT NULL`) 14 | return err 15 | } 16 | -------------------------------------------------------------------------------- /migrations/034_application.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | func application(ctx context.Context, domain string, tx *sql.Tx) error { 11 | _, err := tx.ExecContext(ctx, `update persons set actor = json_set(actor, '$.type', 'Application', '$.updated', ?) where id = ?`, time.Now().Format(time.RFC3339Nano), fmt.Sprintf("https://%s/user/nobody", domain)) 12 | return err 13 | } 14 | -------------------------------------------------------------------------------- /migrations/016_namehost.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func namehost(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `DROP INDEX personspreferredusername`); err != nil { 10 | return err 11 | } 12 | 13 | _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX personspreferredusernamehost ON persons(actor->>'preferredUsername', host)`) 14 | return err 15 | } 16 | -------------------------------------------------------------------------------- /migrations/057_localkeys.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func localkeys(ctx context.Context, domain string, tx *sql.Tx) error { 9 | _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO keys(id, actor) SELECT actor->>'$.publicKey.id', id FROM persons WHERE ed25519privkey IS NOT NULL UNION ALL SELECT actor->>'$.assertionMethod[0].id', id FROM persons WHERE ed25519privkey IS NOT NULL`) 10 | return err 11 | } 12 | -------------------------------------------------------------------------------- /migrations/007_outboxsender.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func outboxsender(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `ALTER TABLE outbox ADD COLUMN sender STRING`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `UPDATE outbox SET sender = activity->>'actor'`); err != nil { 14 | return err 15 | } 16 | 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /migrations/025_certhash.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func certhash(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `DROP INDEX personscerthash`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX personscerthash ON persons(certhash) WHERE certhash IS NOT NULL`); err != nil { 14 | return err 15 | } 16 | 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /migrations/008_thread.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func thread(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `CREATE INDEX notesidinreplytoauthorinserted ON notes(id, object->>'inReplyTo', author, inserted)`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `DROP INDEX notesinreplyto`); err != nil { 14 | return err 15 | } 16 | 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /migrations/009_host.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func host(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ADD COLUMN host TEXT AS (substr(substr(id, 9), 0, instr(substr(id, 9), '/')))`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `CREATE INDEX personshost on persons(host)`); err != nil { 14 | return err 15 | } 16 | 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /migrations/017_outboxhost.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func outboxhost(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `ALTER TABLE outbox ADD COLUMN host TEXT AS (substr(substr(activity->>'id', 9), 0, instr(substr(activity->>'id', 9), '/')))`); err != nil { 10 | return err 11 | } 12 | 13 | _, err := tx.ExecContext(ctx, `CREATE INDEX outboxhostinserted ON outbox(host, inserted)`) 14 | return err 15 | } 16 | -------------------------------------------------------------------------------- /migrations/011_noteshost.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func noteshost(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `ALTER TABLE notes ADD COLUMN host TEXT AS (substr(substr(author, 9), 0, instr(substr(author, 9), '/')))`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `CREATE INDEX noteshostinserted on notes(host, inserted)`); err != nil { 14 | return err 15 | } 16 | 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /migrations/038_resolvegroup.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func resolvegroup(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `DROP INDEX personspreferredusernamehost`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX personspreferredusernamehosttype ON persons(actor->>'$.preferredUsername', host, actor->>'$.type')`); err != nil { 14 | return err 15 | } 16 | 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /front/static/users/settings.gmi: -------------------------------------------------------------------------------- 1 | # ⚙️ Settings 2 | 3 | ## Profile 4 | 5 | => /users/name 📛 Display name 6 | => /users/bio 📜 Bio 7 | => /users/metadata 💳 Metadata 8 | => /users/avatar 🗿 Avatar 9 | 10 | ## Account 11 | 12 | => /users/certificates 🎓 Certificates 13 | => /users/invitations 🎟️ Invitations 14 | => /users/ttl ⏳ Post deletion policy 15 | => /users/export 🥡 Export recent activities 16 | 17 | ## Migration 18 | 19 | => /users/alias 🔗 Set account alias 20 | => /users/move 📦 Move account 21 | => /users/portability 🚲 Data portability 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dimkr/tootik 2 | 3 | go 1.25.5 4 | 5 | require ( 6 | github.com/btcsuite/btcutil v1.0.2 7 | github.com/fsnotify/fsnotify v1.9.0 8 | github.com/google/uuid v1.6.0 9 | github.com/gowebpki/jcs v1.0.1 10 | github.com/mattn/go-sqlite3 v1.14.32 11 | github.com/stretchr/testify v1.11.1 12 | golang.org/x/image v0.34.0 13 | golang.org/x/net v0.48.0 14 | ) 15 | 16 | require ( 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/pmezard/go-difflib v1.0.0 // indirect 19 | golang.org/x/sys v0.39.0 // indirect 20 | gopkg.in/yaml.v3 v3.0.1 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /migrations/026_follows_sync.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func follows_sync(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `CREATE TABLE follows_sync(actor STRING NOT NULL PRIMARY KEY, url STRING NOT NULL, digest STRING NOT NULL, changed INTEGER NOT NULL DEFAULT (UNIXEPOCH()))`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `CREATE INDEX followssyncchanged ON follows_sync(changed)`); err != nil { 14 | return err 15 | } 16 | 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /migrations/014_cleanup.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func cleanup(ctx context.Context, domain string, tx *sql.Tx) error { 9 | _, err := tx.ExecContext(ctx, `DROP TABLE deliveries`) 10 | if err != nil { 11 | return err 12 | } 13 | 14 | if _, err := tx.ExecContext(ctx, `DROP INDEX outboxobjectid`); err != nil { 15 | return err 16 | } 17 | 18 | if _, err := tx.ExecContext(ctx, `CREATE INDEX outboxobjectid ON outbox(activity->>'object.id') WHERE activity->>'object.id' IS NOT NULL`); err != nil { 19 | return err 20 | } 21 | 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /migrations/020_nohash.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func nohash(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `DROP INDEX notesidhash`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `ALTER TABLE notes DROP COLUMN hash`); err != nil { 14 | return err 15 | } 16 | 17 | if _, err := tx.ExecContext(ctx, `DROP INDEX personsidhash`); err != nil { 18 | return err 19 | } 20 | 21 | _, err := tx.ExecContext(ctx, `ALTER TABLE persons DROP COLUMN hash`) 22 | return err 23 | } 24 | -------------------------------------------------------------------------------- /migrations/032_bookmarks.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func bookmarks(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `CREATE TABLE bookmarks(note STRING NOT NULL, by STRING NOT NULL, inserted INTEGER DEFAULT (UNIXEPOCH()))`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `CREATE INDEX bookmarksnote ON bookmarks(note)`); err != nil { 14 | return err 15 | } 16 | 17 | _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX bookmarksbynote ON bookmarks(by, note)`) 18 | return err 19 | } 20 | -------------------------------------------------------------------------------- /migrations/052_invites.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func invites(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `CREATE TABLE invites(code TEXT NOT NULL, certhash TEXT, inviter TEXT NOT NULL, invited TEXT, inserted INTEGER DEFAULT (UNIXEPOCH()))`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `CREATE INDEX invitescerthash ON invites(certhash) WHERE certhash IS NOT NULL`); err != nil { 14 | return err 15 | } 16 | 17 | _, err := tx.ExecContext(ctx, `CREATE INDEX invitesinviter ON invites(inviter)`) 18 | return err 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*' 7 | 8 | jobs: 9 | build: 10 | permissions: {} 11 | uses: ./.github/workflows/build.yml 12 | with: 13 | version: ${{ github.ref_name }} 14 | upload: 15 | permissions: 16 | contents: write 17 | needs: build 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/download-artifact@v4 21 | with: 22 | name: tootik-${{ github.ref_name }} 23 | path: artifacts 24 | - uses: softprops/action-gh-release@v2 25 | with: 26 | files: artifacts/tootik-* 27 | fail_on_unmatched_files: true 28 | -------------------------------------------------------------------------------- /front/user/user.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package user handles user registration. 18 | package user 19 | -------------------------------------------------------------------------------- /httpsig/httpsig.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package httpsig implements HTTP signatures. 18 | package httpsig 19 | -------------------------------------------------------------------------------- /danger/danger.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package danger provides memory unsafe utilities. 18 | package danger 19 | -------------------------------------------------------------------------------- /fed/fed.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package fed implements federation (server-to-server requests). 18 | package fed 19 | -------------------------------------------------------------------------------- /data/data.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package data implements generic data structures and deletion of old data. 18 | package data 19 | -------------------------------------------------------------------------------- /front/text/plain/plain.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package plain converts HTML to plain text and vice versa. 18 | package plain 19 | -------------------------------------------------------------------------------- /front/text/text.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package text is an abstraction layer for different text formats. 18 | package text 19 | -------------------------------------------------------------------------------- /front/front.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package front implements a UI and exposes it over text-based protocols. 18 | package front 19 | -------------------------------------------------------------------------------- /fed/client.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package fed 18 | 19 | import "net/http" 20 | 21 | // Client is a HTTP client. 22 | type Client interface { 23 | Do(*http.Request) (*http.Response, error) 24 | } 25 | -------------------------------------------------------------------------------- /ap/public.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023, 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | // Public is the special ActivityPub collection used for public addressing. 20 | const Public = "https://www.w3.org/ns/activitystreams#Public" 21 | -------------------------------------------------------------------------------- /ap/public_key.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | type PublicKey struct { 20 | ID string `json:"id"` 21 | Owner string `json:"owner"` 22 | PublicKeyPem string `json:"publicKeyPem"` 23 | } 24 | -------------------------------------------------------------------------------- /httpsig/key.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package httpsig 18 | 19 | // Key is used to sign outgoing HTTP requests or verify incoming HTTP requests. 20 | type Key struct { 21 | ID string 22 | PrivateKey any 23 | } 24 | -------------------------------------------------------------------------------- /icon/icon.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023, 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package icon generates tiny, pseudo-random user avatars. 18 | package icon 19 | 20 | const ( 21 | MediaType = "image/gif" 22 | FileNameExtension = ".gif" 23 | ) 24 | -------------------------------------------------------------------------------- /ap/poll_option.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | type PollOption struct { 20 | Name string `json:"name"` 21 | Replies struct { 22 | TotalItems int64 `json:"totalItems"` 23 | } `json:"replies"` 24 | } 25 | -------------------------------------------------------------------------------- /fed/no_pprof.go: -------------------------------------------------------------------------------- 1 | //go:build no_pprof 2 | 3 | /* 4 | Copyright 2025 Dima Krasner 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | package fed 20 | 21 | import "net/http" 22 | 23 | func (l *Listener) withPprof(inner http.Handler) (http.Handler, error) { 24 | return inner, nil 25 | } 26 | -------------------------------------------------------------------------------- /outbox/outbox.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package outbox translates automatic actions performed on behalf of users into outgoing activities. 18 | // 19 | // Outgoing activities are queued and delivered by [fed.Queue]. 20 | package outbox 21 | -------------------------------------------------------------------------------- /ap/ap.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package ap implements types that represent the ActivityPub vocabulary. 18 | // 19 | // These types implement [database/sql.Scanner] and [database/sql/driver.Valuer], allowing them to be stored in a SQL database as a JSON column. 20 | package ap 21 | -------------------------------------------------------------------------------- /front/robots.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023, 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import "github.com/dimkr/tootik/front/text" 20 | 21 | func robots(w text.Writer, r *Request, args ...string) { 22 | w.Status(20, "text/plain") 23 | w.Text("User-agent: *") 24 | w.Text("Disallow: /") 25 | } 26 | -------------------------------------------------------------------------------- /fed/robots.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023, 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package fed 18 | 19 | import "net/http" 20 | 21 | func robots(w http.ResponseWriter, r *http.Request) { 22 | w.Header().Set("Content-Type", "text/plain") 23 | w.Write([]byte("User-agent: *\n")) 24 | w.Write([]byte("Disallow: /\n")) 25 | } 26 | -------------------------------------------------------------------------------- /front/oops.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023, 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import "github.com/dimkr/tootik/front/text" 20 | 21 | func oops(w text.Writer, r *Request, args ...string) { 22 | w.OK() 23 | w.Title("🦖🦖🦖") 24 | w.Empty() 25 | w.Text("You sent an invalid request or this page doesn't exist.") 26 | } 27 | -------------------------------------------------------------------------------- /cluster/line.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package cluster 18 | 19 | type LineType int 20 | 21 | const ( 22 | Text LineType = iota 23 | Link 24 | Heading 25 | SubHeading 26 | Item 27 | Quote 28 | Preformatted 29 | ) 30 | 31 | type Line struct { 32 | Type LineType 33 | Text string 34 | URL string 35 | } 36 | -------------------------------------------------------------------------------- /migrations/028_localforward.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func localforward(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `DROP INDEX outboxactivityid`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX outboxactivityidsender ON outbox(activity->>'$.id', sender)`); err != nil { 14 | return err 15 | } 16 | 17 | if _, err := tx.ExecContext(ctx, `CREATE TABLE deliveries(activity STRING NOT NULL, inbox STRING NOT NULL)`); err != nil { 18 | return err 19 | } 20 | 21 | if _, err := tx.ExecContext(ctx, `CREATE INDEX deliveriesactivity ON deliveries(activity)`); err != nil { 22 | return err 23 | } 24 | 25 | if _, err := tx.ExecContext(ctx, `ALTER TABLE outbox DROP COLUMN received`); err != nil { 26 | return err 27 | } 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /danger/bytes.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package danger 18 | 19 | import "unsafe" 20 | 21 | // Bytes casts a string to a byte slice without copying the underlying array. 22 | // 23 | // The caller must not modify s afterwards because this will change the returned slice. 24 | func Bytes(s string) []byte { 25 | return unsafe.Slice(unsafe.StringData(s), len(s)) 26 | } 27 | -------------------------------------------------------------------------------- /danger/string.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package danger 18 | 19 | import "unsafe" 20 | 21 | // String casts a byte slice to a string without copying the underlying array. 22 | // 23 | // The caller must not modify b afterwards because this will change the returned string. 24 | func String(b []byte) string { 25 | return unsafe.String(unsafe.SliceData(b), len(b)) 26 | } 27 | -------------------------------------------------------------------------------- /front/me.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "strings" 21 | 22 | "github.com/dimkr/tootik/front/text" 23 | ) 24 | 25 | func me(w text.Writer, r *Request, args ...string) { 26 | if r.User == nil { 27 | w.Redirect("/users") 28 | return 29 | } 30 | 31 | w.Redirect("/users/outbox/" + strings.TrimPrefix(r.User.ID, "https://")) 32 | } 33 | -------------------------------------------------------------------------------- /migrations/048_deliverieshost.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func deliverieshost(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `CREATE TABLE ndeliveries(activity TEXT NOT NULL, host TEXT NOT NULL)`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `INSERT INTO ndeliveries(activity, host) SELECT DISTINCT activity, SUBSTR(SUBSTR(inbox, 9), 0, INSTR(SUBSTR(inbox, 9), '/')) FROM deliveries`); err != nil { 14 | return err 15 | } 16 | 17 | if _, err := tx.ExecContext(ctx, `DROP TABLE deliveries`); err != nil { 18 | return err 19 | } 20 | 21 | if _, err := tx.ExecContext(ctx, `ALTER TABLE ndeliveries RENAME TO deliveries`); err != nil { 22 | return err 23 | } 24 | 25 | if _, err := tx.ExecContext(ctx, `CREATE INDEX deliveriesactivity ON deliveries(activity)`); err != nil { 26 | return err 27 | } 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /migrations/024_followeds.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func followeds(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `ALTER TABLE follows ADD COLUMN followeds STRING`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `UPDATE follows SET followeds = followed`); err != nil { 14 | return err 15 | } 16 | 17 | if _, err := tx.ExecContext(ctx, `DROP INDEX followsfollowed`); err != nil { 18 | return err 19 | } 20 | 21 | if _, err := tx.ExecContext(ctx, `ALTER TABLE follows DROP COLUMN followed`); err != nil { 22 | return err 23 | } 24 | 25 | if _, err := tx.ExecContext(ctx, `ALTER TABLE follows RENAME COLUMN followeds TO followed`); err != nil { 26 | return err 27 | } 28 | 29 | if _, err := tx.ExecContext(ctx, `CREATE INDEX followsfollowed ON follows(followed)`); err != nil { 30 | return err 31 | } 32 | 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /ap/key.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | // AssertionMethod contains a public key used to verify requests sent on behalf of an [Actor]. 20 | type AssertionMethod struct { 21 | ID string `json:"id"` 22 | Type string `json:"type"` 23 | Controller string `json:"controller"` 24 | PublicKeyMultibase string `json:"publicKeyMultibase"` 25 | } 26 | -------------------------------------------------------------------------------- /danger/json.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package danger 18 | 19 | import "encoding/json" 20 | 21 | // MarshalJSON encodes a value to a JSON string. 22 | // 23 | // It uses [String] to avoid memory allocation and copying. 24 | func MarshalJSON(v any) (string, error) { 25 | if b, err := json.Marshal(v); err != nil { 26 | return "", err 27 | } else { 28 | return String(b), nil 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /migrations/list.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | # Copyright 2023 Dima Krasner 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | cat << EOF > migrations.go 18 | package migrations 19 | 20 | // auto-generated by list.sh 21 | 22 | var migrations = []migration{ 23 | EOF 24 | 25 | ls [0-9][0-9][0-9]_*.go | sort -n | while read f; do 26 | id=${f%.go} 27 | id=${id#*_} 28 | echo " {\"$id\", $id}," >> migrations.go 29 | done 30 | 31 | echo "}" >> migrations.go 32 | -------------------------------------------------------------------------------- /ap/generator.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | // Implement is a [Generator] capability. 20 | type Implement struct { 21 | Href string `json:"href,omitempty"` 22 | Name string `json:"name,omitempty"` 23 | } 24 | 25 | // Generator generates [Object] objects. 26 | type Generator struct { 27 | Type ActorType `json:"type"` 28 | Implements Array[Implement] `json:"implements,omitzero"` 29 | } 30 | -------------------------------------------------------------------------------- /front/home.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import "github.com/dimkr/tootik/front/text" 20 | 21 | func (h *Handler) home(w text.Writer, r *Request, args ...string) { 22 | if r.User != nil { 23 | w.Redirect("/users") 24 | return 25 | } 26 | 27 | w.OK() 28 | w.Raw(logoAlt, logo) 29 | w.Title(h.Domain) 30 | w.Textf("Welcome, fedinaut! %s is a text-based social network.", h.Domain) 31 | } 32 | -------------------------------------------------------------------------------- /ap/tag.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023, 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | type TagType string 20 | 21 | const ( 22 | Mention TagType = "Mention" 23 | Hashtag TagType = "Hashtag" 24 | Emoji TagType = "Emoji" 25 | ) 26 | 27 | type Tag struct { 28 | Type TagType `json:"type,omitempty"` 29 | Name string `json:"name,omitempty"` 30 | Href string `json:"href,omitempty"` 31 | Icon *Attachment `json:"icon,omitempty"` 32 | } 33 | -------------------------------------------------------------------------------- /ap/policy.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | // QuotePolicy describes quote policies for an [Object]. 20 | type QuotePolicy struct { 21 | AutomaticApproval Audience `json:"automaticApproval,omitzero"` 22 | ManualApproval Audience `json:"manualApproval,omitzero"` 23 | } 24 | 25 | // InteractionPolicy describes interaction policies for an [Object]. 26 | type InteractionPolicy struct { 27 | CanQuote QuotePolicy `json:"canQuote,omitzero"` 28 | } 29 | -------------------------------------------------------------------------------- /ap/proof.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | type Proof struct { 20 | Context any `json:"@context,omitempty"` 21 | Type string `json:"type"` 22 | CryptoSuite string `json:"cryptosuite"` 23 | VerificationMethod string `json:"verificationMethod"` 24 | Purpose string `json:"proofPurpose"` 25 | Value string `json:"proofValue,omitempty"` 26 | Created string `json:"created"` 27 | } 28 | -------------------------------------------------------------------------------- /migrations/add.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | # Copyright 2023, 2024 Dima Krasner 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | last=`ls migrations/[0-9][0-9][0-9]_*.go | sort -n | tail -n 1 | cut -f 2 -d / | cut -f 1 -d _ | sed 's/^0*//g'` 18 | new=migrations/`printf "%03d" $(($last+1))`_$1.go 19 | 20 | echo "Creating $new" 21 | 22 | cat << EOF > $new 23 | package migrations 24 | 25 | import ( 26 | "context" 27 | "database/sql" 28 | ) 29 | 30 | func $1(ctx context.Context, domain string, tx *sql.Tx) error { 31 | // do stuff 32 | 33 | return nil 34 | } 35 | EOF 36 | -------------------------------------------------------------------------------- /data/id.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package data 18 | 19 | import ( 20 | "net/url" 21 | "strings" 22 | ) 23 | 24 | // IsIDValid determines whether or not a URL can be a valid actor, object or activity ID. 25 | func IsIDValid(u *url.URL) bool { 26 | if u.Scheme != "https" { 27 | return false 28 | } 29 | 30 | if u.User != nil { 31 | return false 32 | } 33 | 34 | if u.RawQuery != "" { 35 | return false 36 | } 37 | 38 | if strings.Contains(u.Path, "/..") { 39 | return false 40 | } 41 | 42 | return true 43 | } 44 | -------------------------------------------------------------------------------- /migrations/031_feed.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func feed(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `CREATE TABLE feed(follower STRING NOT NULL, note STRING NOT NULL, author STRING NOT NULL, sharer STRING, inserted INTEGER NOT NULL)`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `CREATE INDEX feedfollowerinserted ON feed(follower, inserted)`); err != nil { 14 | return err 15 | } 16 | 17 | if _, err := tx.ExecContext(ctx, `CREATE INDEX feedinserted ON feed(inserted)`); err != nil { 18 | return err 19 | } 20 | 21 | if _, err := tx.ExecContext(ctx, `CREATE INDEX feednote ON feed(note->>'$.id')`); err != nil { 22 | return err 23 | } 24 | 25 | if _, err := tx.ExecContext(ctx, `CREATE INDEX feedauthorid ON feed(author->>'$.id')`); err != nil { 26 | return err 27 | } 28 | 29 | if _, err := tx.ExecContext(ctx, `CREATE INDEX feedshareid ON feed(sharer->>'$.id') WHERE sharer->>'$.id' IS NOT NULL`); err != nil { 30 | return err 31 | } 32 | 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /migrations/036_rawforward.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func rawforward(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `CREATE TABLE newinbox(id INTEGER PRIMARY KEY, sender STRING NOT NULL, activity STRING NOT NULL, raw STRING NOT NULL, inserted INTEGER DEFAULT (UNIXEPOCH()))`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `INSERT INTO newinbox(id, sender, activity, raw, inserted) SELECT id, sender, activity, activity AS raw, inserted FROM inbox`); err != nil { 14 | return err 15 | } 16 | 17 | if _, err := tx.ExecContext(ctx, `DROP INDEX inboxid`); err != nil { 18 | return err 19 | } 20 | 21 | if _, err := tx.ExecContext(ctx, `DROP TABLE inbox`); err != nil { 22 | return err 23 | } 24 | 25 | if _, err := tx.ExecContext(ctx, `ALTER TABLE newinbox RENAME TO inbox`); err != nil { 26 | return err 27 | } 28 | 29 | if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX inboxid ON inbox(activity->>'$.id')`); err != nil { 30 | return err 31 | } 32 | 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /fed/redirect.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package fed 18 | 19 | import ( 20 | "net/http" 21 | "strings" 22 | ) 23 | 24 | func shouldRedirect(r *http.Request) bool { 25 | accept := strings.ReplaceAll(r.Header.Get("Accept"), " ", "") 26 | return (accept == "text/html" || strings.HasPrefix(accept, "text/html,") || strings.HasSuffix(accept, ",text/html") || strings.Contains(accept, ",text/html,")) && !strings.Contains(accept, "application/activity+json") && !strings.Contains(accept, `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`) 27 | } 28 | -------------------------------------------------------------------------------- /ap/capability.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | // Capability is a capability that may be supported by an ActivityPub server. 20 | type Capability uint 21 | 22 | const ( 23 | // CavageDraftSignatures is support for draft-cavage-http-signatures, with rsa-sha256. 24 | CavageDraftSignatures Capability = 1 << iota 25 | 26 | // RFC9421RSASignatures is support for RFC9421 HTTP signatures, with rsa-v1_5-sha256. 27 | RFC9421RSASignatures 28 | 29 | // RFC9421Ed25519Signatures is support for RFC9421 HTTP signatures, with Ed25119 keys. 30 | RFC9421Ed25519Signatures 31 | ) 32 | -------------------------------------------------------------------------------- /ap/time.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | import ( 20 | "time" 21 | 22 | "github.com/dimkr/tootik/danger" 23 | ) 24 | 25 | // Time is a wrapper around [time.Time] with fallback if parsing of RFC3339 fails. 26 | type Time struct { 27 | time.Time 28 | } 29 | 30 | func (t *Time) UnmarshalJSON(b []byte) error { 31 | err := t.Time.UnmarshalJSON(b) 32 | // ugly hack for Threads 33 | if err != nil && len(b) > 2 && b[0] == '"' && b[len(b)-1] == '"' { 34 | t.Time, err = time.Parse("2006-01-02T15:04:05-0700", danger.String(b[1:len(b)-1])) 35 | } 36 | return err 37 | } 38 | -------------------------------------------------------------------------------- /fed/hostmeta.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023, 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package fed 18 | 19 | import ( 20 | "fmt" 21 | "net/http" 22 | ) 23 | 24 | func addHostMeta(mux *http.ServeMux, domain string) { 25 | mux.HandleFunc("GET /.well-known/host-meta", func(w http.ResponseWriter, r *http.Request) { 26 | w.Header().Set("Content-Type", "application/xrd+xml; charset=utf-8") 27 | fmt.Fprintf(w, ` 28 | 29 | 30 | 31 | `, domain) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /lock/lock.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package lock provides synchronization primitives. 18 | package lock 19 | 20 | import "context" 21 | 22 | // Lock is similar to [sync.Mutex] but locking is cancellable through a [context.Context]. 23 | type Lock chan struct{} 24 | 25 | func New() Lock { 26 | c := make(chan struct{}, 1) 27 | c <- struct{}{} 28 | return c 29 | } 30 | 31 | func (l Lock) Lock(ctx context.Context) error { 32 | select { 33 | case <-ctx.Done(): 34 | return ctx.Err() 35 | case <-l: 36 | return nil 37 | } 38 | } 39 | 40 | func (l Lock) Unlock() { 41 | l <- struct{}{} 42 | } 43 | -------------------------------------------------------------------------------- /front/unbookmark.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import "github.com/dimkr/tootik/front/text" 20 | 21 | func (h *Handler) unbookmark(w text.Writer, r *Request, args ...string) { 22 | if r.User == nil { 23 | w.Redirect("/users") 24 | return 25 | } 26 | 27 | postID := "https://" + args[1] 28 | 29 | if _, err := h.DB.ExecContext(r.Context, `delete from bookmarks where note = ? and by = ?`, postID, r.User.ID); err != nil { 30 | r.Log.Warn("Failed to delete bookmark", "post", postID, "error", err) 31 | w.Error() 32 | return 33 | } 34 | 35 | w.Redirectf("/users/view/" + args[1]) 36 | } 37 | -------------------------------------------------------------------------------- /fed/index.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package fed 18 | 19 | import "net/http" 20 | 21 | func (l *Listener) handleIndex(w http.ResponseWriter, r *http.Request) { 22 | // this is how PieFed fetches the instance actor to detect its inbox and use it as a shared inbox for this instance 23 | if accept := r.Header.Get("Accept"); accept == "application/activity+json" || accept == `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` { 24 | l.doHandleUser(w, r, "actor") 25 | return 26 | } 27 | 28 | w.Header().Set("Location", "gemini://"+l.Domain) 29 | w.WriteHeader(http.StatusMovedPermanently) 30 | } 31 | -------------------------------------------------------------------------------- /test/home_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package test 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/stretchr/testify/assert" 23 | ) 24 | 25 | func TestHome_AuthenticatedUser(t *testing.T) { 26 | server := newTestServer() 27 | defer server.Shutdown() 28 | 29 | assert := assert.New(t) 30 | 31 | home := server.Handle("/", server.Alice) 32 | assert.Equal("30 /users\r\n", home) 33 | } 34 | 35 | func TestHome_UnauthenticatedUser(t *testing.T) { 36 | server := newTestServer() 37 | defer server.Shutdown() 38 | 39 | assert := assert.New(t) 40 | 41 | home := server.Handle("/", nil) 42 | assert.Regexp("^20 text/gemini\r\n", home) 43 | } 44 | -------------------------------------------------------------------------------- /ap/array.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | import "encoding/json" 20 | 21 | // Array is an array or a single item. 22 | type Array[T any] []T 23 | 24 | func (a Array[T]) IsZero() bool { 25 | return len(a) == 0 26 | } 27 | 28 | func (a *Array[T]) UnmarshalJSON(b []byte) error { 29 | var tmp []T 30 | if err := json.Unmarshal(b, &tmp); err != nil { 31 | tmp = make([]T, 1) 32 | if err := json.Unmarshal(b, &tmp[0]); err != nil { 33 | return err 34 | } 35 | } 36 | 37 | *a = tmp 38 | return nil 39 | } 40 | 41 | func (a Array[T]) MarshalJSON() ([]byte, error) { 42 | if a == nil { 43 | return []byte("[]"), nil 44 | } 45 | 46 | return json.Marshal(([]T)(a)) 47 | } 48 | -------------------------------------------------------------------------------- /inbox/id.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package inbox 18 | 19 | import ( 20 | "fmt" 21 | 22 | "github.com/dimkr/tootik/ap" 23 | "github.com/google/uuid" 24 | ) 25 | 26 | // NewID generates a pseudo-random ID. 27 | func (inbox *Inbox) NewID(actorID, prefix string) (string, error) { 28 | u, err := uuid.NewV7() 29 | if err != nil { 30 | return "", fmt.Errorf("failed to generate %s ID: %w", prefix, err) 31 | } 32 | 33 | if m := ap.GatewayURLRegex.FindStringSubmatch(actorID); m != nil { 34 | return fmt.Sprintf("https://%s/.well-known/apgateway/did:key:%s/actor/%s/%s", inbox.Domain, m[1], prefix, u.String()), nil 35 | } 36 | 37 | return fmt.Sprintf("https://%s/%s/%s", inbox.Domain, prefix, u.String()), nil 38 | } 39 | -------------------------------------------------------------------------------- /test/static_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package test 18 | 19 | import ( 20 | "strings" 21 | "testing" 22 | 23 | "github.com/stretchr/testify/assert" 24 | ) 25 | 26 | func TestHelpUnauthenticatedUser(t *testing.T) { 27 | server := newTestServer() 28 | defer server.Shutdown() 29 | 30 | assert := assert.New(t) 31 | 32 | help := server.Handle("/help", nil) 33 | assert.Contains(strings.Split(help, "\n"), "# 🛟 Help") 34 | } 35 | 36 | func TestHelpAuthenticatedUser(t *testing.T) { 37 | server := newTestServer() 38 | defer server.Shutdown() 39 | 40 | assert := assert.New(t) 41 | 42 | help := server.Handle("/users/help", server.Alice) 43 | assert.Contains(strings.Split(help, "\n"), "# 🛟 Help") 44 | } 45 | -------------------------------------------------------------------------------- /data/key_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package data 18 | 19 | import ( 20 | "bytes" 21 | "testing" 22 | ) 23 | 24 | // https://codeberg.org/fediverse/fep/src/commit/480415584237eb19cb7373b6a25faa6fa6e3a200/fep/521b/fep-521b.md 25 | func Test_FEP521b(t *testing.T) { 26 | a, err := DecodeEd25519PublicKey("u7QGwDY2Tjn93PVFWWq02piP1NE9_XRlg-c8-jhJiDqKBDw") 27 | if err != nil { 28 | t.Fatalf("Failed to decode base64-encoded key: %v", err) 29 | } 30 | 31 | b, err := DecodeEd25519PublicKey("z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2") 32 | if err != nil { 33 | t.Fatalf("Failed to decode base58-encoded key: %v", err) 34 | } 35 | 36 | if !bytes.Equal(a, b) { 37 | t.Fatal("Keys are different") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /migrations/021_shares.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func shares(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `DROP INDEX notesidinreplytoauthorinserted`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `CREATE INDEX notesidinreplytoauthorinserted ON notes(id, object->>'inReplyTo', author, inserted) WHERE object->>'inReplyTo' IS NOT NULL`); err != nil { 14 | return err 15 | } 16 | 17 | if _, err := tx.ExecContext(ctx, `CREATE TABLE shares(note STRING NOT NULL, by STRING NOT NULL, inserted INTEGER DEFAULT (UNIXEPOCH()))`); err != nil { 18 | return err 19 | } 20 | 21 | if _, err := tx.ExecContext(ctx, `CREATE INDEX sharesnote ON shares(note)`); err != nil { 22 | return err 23 | } 24 | 25 | if _, err := tx.ExecContext(ctx, `CREATE INDEX sharesbyinserted ON shares(by, inserted)`); err != nil { 26 | return err 27 | } 28 | 29 | if _, err := tx.ExecContext(ctx, `DROP INDEX notesgroupid`); err != nil { 30 | return err 31 | } 32 | 33 | if _, err := tx.ExecContext(ctx, `ALTER TABLE notes DROP COLUMN groupid`); err != nil { 34 | return err 35 | } 36 | 37 | _, err := tx.ExecContext(ctx, `CREATE INDEX notesaudience ON notes(object->>'audience')`) 38 | return err 39 | } 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2023 - 2025 Dima Krasner 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | FROM golang:1.25-alpine AS build 16 | RUN apk add --no-cache gcc musl-dev openssl 17 | COPY go.mod /src/ 18 | COPY go.sum /src/ 19 | WORKDIR /src 20 | RUN go mod download 21 | COPY migrations /src/migrations 22 | RUN go generate ./migrations 23 | COPY . /src 24 | RUN go vet ./... 25 | RUN go test ./... -failfast -vet off -tags fts5 26 | ARG TOOTIK_VERSION=? 27 | RUN go build -ldflags "-X github.com/dimkr/tootik/buildinfo.Version=$TOOTIK_VERSION" -tags fts5 ./cmd/tootik 28 | 29 | FROM alpine 30 | RUN apk add --no-cache ca-certificates openssl 31 | COPY --from=build /src/tootik / 32 | COPY --from=build /src/LICENSE / 33 | RUN adduser -D tootik 34 | USER tootik 35 | WORKDIR /tmp 36 | ENTRYPOINT ["/tootik"] 37 | -------------------------------------------------------------------------------- /cluster/name_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package cluster 18 | 19 | import "testing" 20 | 21 | func TestName_Set(t *testing.T) { 22 | cluster := NewCluster(t, "a.localdomain", "b.localdomain") 23 | defer cluster.Stop() 24 | 25 | alice := cluster["a.localdomain"].Register(aliceKeypair).OK() 26 | bob := cluster["b.localdomain"].Register(bobKeypair).OK() 27 | 28 | bob. 29 | Follow("⚙️ Settings"). 30 | Follow("📛 Display name"). 31 | Contains(Line{Type: Text, Text: "Display name is not set."}). 32 | FollowInput("Set", "bobby"). 33 | NotContains(Line{Type: Text, Text: "Display name: bobby."}) 34 | 35 | alice. 36 | FollowInput("🔭 View profile", "bob@b.localdomain"). 37 | Contains(Line{Type: Heading, Text: "👽 bobby (bob@b.localdomain)"}) 38 | } 39 | -------------------------------------------------------------------------------- /migrations/051_rsapkcs8.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/x509" 7 | "database/sql" 8 | "encoding/pem" 9 | ) 10 | 11 | func rsapkcs8(ctx context.Context, domain string, tx *sql.Tx) error { 12 | if rows, err := tx.QueryContext(ctx, `SELECT id, rsaprivkey FROM persons WHERE rsaprivkey IS NOT NULL`); err != nil { 13 | return err 14 | } else { 15 | defer rows.Close() 16 | 17 | for rows.Next() { 18 | var id, rsaPrivKeyPem string 19 | if err := rows.Scan(&id, &rsaPrivKeyPem); err != nil { 20 | return err 21 | } 22 | 23 | p, _ := pem.Decode([]byte(rsaPrivKeyPem)) 24 | 25 | priv, err := x509.ParsePKCS1PrivateKey(p.Bytes) 26 | if err != nil { 27 | continue 28 | } 29 | 30 | privDer, err := x509.MarshalPKCS8PrivateKey(priv) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | var privPem bytes.Buffer 36 | if err := pem.Encode( 37 | &privPem, 38 | &pem.Block{ 39 | Type: "PRIVATE KEY", 40 | Bytes: privDer, 41 | }, 42 | ); err != nil { 43 | return err 44 | } 45 | 46 | if _, err := tx.ExecContext(ctx, `UPDATE persons SET rsaprivkey = ? WHERE id = ?`, privPem.String(), id); err != nil { 47 | return err 48 | } 49 | } 50 | 51 | if err := rows.Err(); err != nil { 52 | return err 53 | } 54 | } 55 | 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /migrations/056_actor.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "crypto/ed25519" 6 | "database/sql" 7 | "fmt" 8 | 9 | "github.com/dimkr/tootik/ap" 10 | "github.com/dimkr/tootik/httpsig" 11 | "github.com/dimkr/tootik/proof" 12 | ) 13 | 14 | func actor(ctx context.Context, domain string, tx *sql.Tx) error { 15 | if rows, err := tx.QueryContext(ctx, `SELECT JSON(actor), ed25519privkey FROM persons WHERE ed25519privkey IS NOT NULL AND actor->>'$.endpoints.sharedInbox' IS NOT NULL`); err != nil { 16 | return err 17 | } else { 18 | defer rows.Close() 19 | 20 | sharedInbox := fmt.Sprintf("https://%s/inbox", domain) 21 | 22 | for rows.Next() { 23 | var actor ap.Actor 24 | var ed25519PrivKey []byte 25 | if err := rows.Scan(&actor, &ed25519PrivKey); err != nil { 26 | return err 27 | } 28 | 29 | actor.Endpoints["sharedInbox"] = sharedInbox 30 | 31 | actor.Proof, err = proof.Create(httpsig.Key{ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, &actor) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | if _, err := tx.ExecContext(ctx, `UPDATE persons SET actor = JSONB(?) WHERE id = ?`, &actor, actor.ID); err != nil { 37 | return err 38 | } 39 | } 40 | 41 | if err := rows.Err(); err != nil { 42 | return err 43 | } 44 | } 45 | 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /front/users.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "database/sql" 21 | 22 | "github.com/dimkr/tootik/front/text" 23 | ) 24 | 25 | func (h *Handler) users(w text.Writer, r *Request, args ...string) { 26 | if r.User == nil { 27 | w.Redirect("/oops") 28 | return 29 | } 30 | 31 | h.showFeedPage( 32 | w, 33 | r, 34 | "📻 My Feed", 35 | func(offset int) (*sql.Rows, error) { 36 | return h.DB.QueryContext( 37 | r.Context, 38 | `select json(note), json(author), json(sharer), inserted from 39 | feed 40 | where 41 | follower = $1 42 | order by 43 | inserted desc 44 | limit $2 45 | offset $3`, 46 | r.User.ID, 47 | h.Config.PostsPerPage, 48 | offset, 49 | ) 50 | }, 51 | true, 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /test/status_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package test 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/stretchr/testify/assert" 23 | ) 24 | 25 | func TestStatus_NewInstance(t *testing.T) { 26 | server := newTestServer() 27 | defer server.Shutdown() 28 | 29 | assert := assert.New(t) 30 | 31 | status := server.Handle("/status", server.Alice) 32 | assert.Regexp("^20 text/gemini\r\n", status) 33 | } 34 | 35 | func TestStatus_WithPosts(t *testing.T) { 36 | server := newTestServer() 37 | defer server.Shutdown() 38 | 39 | assert := assert.New(t) 40 | 41 | whisper := server.Handle("/users/whisper?Hello%20world", server.Alice) 42 | assert.Regexp(`^30 /users/view/\S+\r\n$`, whisper) 43 | 44 | status := server.Handle("/status", server.Alice) 45 | assert.Regexp("^20 text/gemini\r\n", status) 46 | } 47 | -------------------------------------------------------------------------------- /front/request.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "context" 21 | "io" 22 | "log/slog" 23 | "net/url" 24 | 25 | "github.com/dimkr/tootik/ap" 26 | "github.com/dimkr/tootik/httpsig" 27 | ) 28 | 29 | // Request represents a request. 30 | type Request struct { 31 | // Context specifies the request context. 32 | Context context.Context 33 | 34 | // URL specifies the requested URL. 35 | URL *url.URL 36 | 37 | // Log specifies a slog.Logger used while handling the request. 38 | Log *slog.Logger 39 | 40 | // Body optionally specifies an io.Reader to read the request body from. 41 | Body io.Reader 42 | 43 | // User optionally specifies a signed in user. 44 | User *ap.Actor 45 | 46 | // Keys optionally specifies the signing keys associated with User. 47 | Keys [2]httpsig.Key 48 | } 49 | -------------------------------------------------------------------------------- /front/search.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "net/url" 21 | 22 | "github.com/dimkr/tootik/front/text" 23 | ) 24 | 25 | func search(w text.Writer, r *Request, args ...string) { 26 | if r.URL.RawQuery == "" { 27 | w.Status(10, "Hashtag") 28 | return 29 | } 30 | 31 | hashtag, err := url.QueryUnescape(r.URL.RawQuery) 32 | if err != nil { 33 | r.Log.Info("Failed to decode query", "url", r.URL, "error", err) 34 | w.Status(40, "Bad input") 35 | return 36 | } 37 | 38 | if r.User == nil && hashtag[0] == '#' { 39 | w.Redirect("/hashtag/" + hashtag[1:]) 40 | } else if r.User == nil { 41 | w.Redirect("/hashtag/" + hashtag) 42 | } else if hashtag[0] == '#' { 43 | w.Redirect("/users/hashtag/" + hashtag[1:]) 44 | } else { 45 | w.Redirect("/users/hashtag/" + hashtag) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /ap/id_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | import "testing" 20 | 21 | // https://codeberg.org/fediverse/fep/src/commit/480415584237eb19cb7373b6a25faa6fa6e3a200/fep/521b/fep-521b.md 22 | func Test_FEP521b(t *testing.T) { 23 | if m := KeyRegex.FindStringSubmatch("https://invalid.invalid/.well-known/apgateway/did:key:u7QGwDY2Tjn93PVFWWq02piP1NE9_XRlg-c8-jhJiDqKBDw/actor#ed25519-key"); m == nil || m[1] != "u7QGwDY2Tjn93PVFWWq02piP1NE9_XRlg-c8-jhJiDqKBDw" { 24 | t.Fatalf("Failed to detect base64-encoded key") 25 | } 26 | 27 | if m := KeyRegex.FindStringSubmatch("https://invalid.invalid/.well-known/apgateway/did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2/actor#ed25519-key"); m == nil || m[1] != "z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2" { 28 | t.Fatalf("Failed to detect base58-encoded key") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /front/dm.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "github.com/dimkr/tootik/ap" 21 | "github.com/dimkr/tootik/front/text" 22 | ) 23 | 24 | func (h *Handler) dm(w text.Writer, r *Request, args ...string) { 25 | if r.User == nil { 26 | w.Redirect("/users") 27 | return 28 | } 29 | 30 | to := ap.Audience{} 31 | cc := ap.Audience{} 32 | 33 | h.post(w, r, nil, nil, "", to, cc, "", func() (string, bool) { 34 | return readQuery(w, r, "Post content") 35 | }) 36 | } 37 | 38 | func (h *Handler) uploadDM(w text.Writer, r *Request, args ...string) { 39 | if r.User == nil { 40 | w.Redirect("/users") 41 | return 42 | } 43 | 44 | to := ap.Audience{} 45 | cc := ap.Audience{} 46 | 47 | h.post(w, r, nil, nil, "", to, cc, "", func() (string, bool) { 48 | return h.readBody(w, r, args) 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /front/text/writer.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023, 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package text 18 | 19 | import "io" 20 | 21 | // Writer builds a textual response. 22 | // The response is buffered: call [Writer.Flush] to flush the last chunk to the underlying [io.Writer]. 23 | type Writer interface { 24 | io.Writer 25 | 26 | Clone(io.Writer) Writer 27 | Unwrap() io.Writer 28 | 29 | Flush() error 30 | 31 | OK() 32 | Error() 33 | Redirect(string) 34 | Redirectf(string, ...any) 35 | Status(int, string) 36 | Statusf(int, string, ...any) 37 | Title(string) 38 | Titlef(string, ...any) 39 | Subtitle(string) 40 | Subtitlef(string, ...any) 41 | Text(string) 42 | Textf(string, ...any) 43 | Empty() 44 | Link(string, string) 45 | Linkf(string, string, ...any) 46 | Item(string) 47 | Itemf(string, ...any) 48 | Quote(string) 49 | Quotef(string, ...any) 50 | Raw(string, string) 51 | Separator() 52 | } 53 | -------------------------------------------------------------------------------- /migrations/035_certificates.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func certificates(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `CREATE TABLE certificates(user TEXT NOT NULL, hash TEXT NOT NULL, approved INTEGER DEFAULT 0, expires INTEGER, inserted INTEGER DEFAULT (UNIXEPOCH()))`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `INSERT INTO certificates(user, hash, expires, approved) SELECT actor->>'$.preferredUsername', UPPER(certhash), 4102444800, 1 FROM persons WHERE certhash IS NOT NULL`); err != nil { 14 | return err 15 | } 16 | 17 | if _, err := tx.ExecContext(ctx, `DROP INDEX personscerthash`); err != nil { 18 | return err 19 | } 20 | 21 | if _, err := tx.ExecContext(ctx, `ALTER TABLE persons DROP COLUMN certhash`); err != nil { 22 | return err 23 | } 24 | 25 | if _, err := tx.ExecContext(ctx, `CREATE INDEX certificatesinserted ON certificates(inserted)`); err != nil { 26 | return err 27 | } 28 | 29 | if _, err := tx.ExecContext(ctx, `CREATE INDEX certificateswaiting ON certificates(inserted) WHERE approved = 0`); err != nil { 30 | return err 31 | } 32 | 33 | if _, err := tx.ExecContext(ctx, `CREATE INDEX certificatesexpires ON certificates(expires)`); err != nil { 34 | return err 35 | } 36 | 37 | _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX certificateshash ON certificates(hash)`) 38 | return err 39 | } 40 | -------------------------------------------------------------------------------- /migrations/039_reject.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func reject(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `DROP INDEX followsfollower`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `DROP INDEX followsfollowed`); err != nil { 14 | return err 15 | } 16 | 17 | if _, err := tx.ExecContext(ctx, `CREATE TABLE newfollows(id STRING NOT NULL PRIMARY KEY, follower STRING NOT NULL, inserted INTEGER DEFAULT (UNIXEPOCH()), accepted INTEGER, followed STRING)`); err != nil { 18 | return err 19 | } 20 | 21 | if _, err := tx.ExecContext(ctx, `INSERT INTO newfollows(id, follower, inserted, accepted, followed) SELECT id, follower, inserted, accepted, followed FROM follows`); err != nil { 22 | return err 23 | } 24 | 25 | if _, err := tx.ExecContext(ctx, `DROP TABLE follows`); err != nil { 26 | return err 27 | } 28 | 29 | if _, err := tx.ExecContext(ctx, `ALTER TABLE newfollows RENAME TO follows`); err != nil { 30 | return err 31 | } 32 | 33 | if _, err := tx.ExecContext(ctx, `CREATE INDEX followsfollower ON follows(follower)`); err != nil { 34 | return err 35 | } 36 | 37 | if _, err := tx.ExecContext(ctx, `CREATE INDEX followsfollowed ON follows(followed)`); err != nil { 38 | return err 39 | } 40 | 41 | _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX followsfollowerfollowed ON follows(follower, followed)`) 42 | return err 43 | } 44 | -------------------------------------------------------------------------------- /front/text/wrap.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023, 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package text 18 | 19 | // WordWrap wraps long lines. 20 | func WordWrap(text string, width, maxLines int) []string { 21 | if text == "" { 22 | return []string{""} 23 | } 24 | 25 | runes := []rune(text) 26 | var lines []string 27 | 28 | for i := 0; i < len(runes) && (maxLines == -1 || len(lines) <= maxLines); { 29 | if i >= len(runes)-width { 30 | lines = append(lines, string(runes[i:])) 31 | break 32 | } 33 | 34 | lastSpace := -1 35 | 36 | for j := i + width - 1; j > i; j-- { 37 | if runes[j] == ' ' || runes[j] == '\t' { 38 | lastSpace = j 39 | break 40 | } 41 | } 42 | 43 | if lastSpace >= 0 { 44 | lines = append(lines, string(runes[i:lastSpace])) 45 | i = lastSpace + 1 46 | } else { 47 | lines = append(lines, string(runes[i:i+width])) 48 | i += width 49 | } 50 | } 51 | 52 | return lines 53 | } 54 | -------------------------------------------------------------------------------- /cluster/bio_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package cluster 18 | 19 | import "testing" 20 | 21 | func TestBio_Set(t *testing.T) { 22 | cluster := NewCluster(t, "a.localdomain", "b.localdomain") 23 | defer cluster.Stop() 24 | 25 | alice := cluster["a.localdomain"].Register(aliceKeypair).OK() 26 | bob := cluster["b.localdomain"].Register(bobKeypair).OK() 27 | 28 | bob. 29 | Follow("⚙️ Settings"). 30 | Follow("📜 Bio"). 31 | Contains(Line{Type: Text, Text: "Bio is empty."}). 32 | FollowInput("Set", "hello world\nthis is my bio"). 33 | NotContains(Line{Type: Text, Text: "Bio is empty."}). 34 | Contains(Line{Type: Quote, Text: "hello world"}). 35 | Contains(Line{Type: Quote, Text: "this is my bio"}) 36 | 37 | alice. 38 | FollowInput("🔭 View profile", "bob@b.localdomain"). 39 | Contains(Line{Type: Quote, Text: "hello world"}). 40 | Contains(Line{Type: Quote, Text: "this is my bio"}) 41 | } 42 | -------------------------------------------------------------------------------- /front/whisper.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "github.com/dimkr/tootik/ap" 21 | "github.com/dimkr/tootik/front/text" 22 | ) 23 | 24 | func (h *Handler) whisper(w text.Writer, r *Request, args ...string) { 25 | if r.User == nil { 26 | w.Redirect("/users") 27 | return 28 | } 29 | 30 | to := ap.Audience{} 31 | cc := ap.Audience{} 32 | 33 | to.Add(r.User.Followers) 34 | 35 | h.post(w, r, nil, nil, "", to, cc, "", func() (string, bool) { 36 | return readQuery(w, r, "Post content") 37 | }) 38 | } 39 | 40 | func (h *Handler) uploadWhisper(w text.Writer, r *Request, args ...string) { 41 | if r.User == nil { 42 | w.Redirect("/users") 43 | return 44 | } 45 | 46 | to := ap.Audience{} 47 | cc := ap.Audience{} 48 | 49 | to.Add(r.User.Followers) 50 | 51 | h.post(w, r, nil, nil, "", to, cc, "", func() (string, bool) { 52 | return h.readBody(w, r, args) 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /front/say.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "github.com/dimkr/tootik/ap" 21 | "github.com/dimkr/tootik/front/text" 22 | ) 23 | 24 | func (h *Handler) say(w text.Writer, r *Request, args ...string) { 25 | if r.User == nil { 26 | w.Redirect("/users") 27 | return 28 | } 29 | 30 | to := ap.Audience{} 31 | cc := ap.Audience{} 32 | 33 | to.Add(ap.Public) 34 | cc.Add(r.User.Followers) 35 | 36 | h.post(w, r, nil, nil, "", to, cc, "", func() (string, bool) { 37 | return readQuery(w, r, "Post content") 38 | }) 39 | } 40 | 41 | func (h *Handler) uploadSay(w text.Writer, r *Request, args ...string) { 42 | if r.User == nil { 43 | w.Redirect("/users") 44 | return 45 | } 46 | 47 | to := ap.Audience{} 48 | cc := ap.Audience{} 49 | 50 | to.Add(ap.Public) 51 | cc.Add(r.User.Followers) 52 | 53 | h.post(w, r, nil, nil, "", to, cc, "", func() (string, bool) { 54 | return h.readBody(w, r, args) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /front/mentions.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "database/sql" 21 | 22 | "github.com/dimkr/tootik/front/text" 23 | ) 24 | 25 | func (h *Handler) mentions(w text.Writer, r *Request, args ...string) { 26 | if r.User == nil { 27 | w.Redirect("/users") 28 | return 29 | } 30 | 31 | h.showFeedPage( 32 | w, 33 | r, 34 | "📞 Mentions", 35 | func(offset int) (*sql.Rows, error) { 36 | return h.DB.QueryContext( 37 | r.Context, 38 | `select json(note), json(author), json(sharer), inserted from 39 | feed 40 | where 41 | follower = $1 and 42 | ( 43 | exists (select 1 from json_each(note->'$.to') where value = $1) or 44 | exists (select 1 from json_each(note->'$.cc') where value = $1) 45 | ) 46 | order by 47 | inserted desc 48 | limit $2 49 | offset $3`, 50 | r.User.ID, 51 | h.Config.PostsPerPage, 52 | offset, 53 | ) 54 | }, 55 | true, 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /ap/resolver.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | import ( 20 | "context" 21 | "net/http" 22 | 23 | "github.com/dimkr/tootik/httpsig" 24 | ) 25 | 26 | type ResolverFlag uint 27 | 28 | const ( 29 | // Offline disables fetching of remote actors and forces use of local or cached actors. 30 | Offline ResolverFlag = 1 31 | 32 | // InstanceActor enables discovery of the "instance actor" instead of the regular actor discovery flow. 33 | InstanceActor = 2 34 | 35 | // GroupActor makes [Resolver] prefer the first [Group] actor in the WebFinger response. 36 | GroupActor = 4 37 | ) 38 | 39 | // Resolver retrieves [Actor], [Object] and [Activity] objects. 40 | type Resolver interface { 41 | ResolveID(ctx context.Context, keys [2]httpsig.Key, id string, flags ResolverFlag) (*Actor, error) 42 | Resolve(ctx context.Context, keys [2]httpsig.Key, host, name string, flags ResolverFlag) (*Actor, error) 43 | Get(ctx context.Context, keys [2]httpsig.Key, url string) (*http.Response, error) 44 | } 45 | -------------------------------------------------------------------------------- /migrations/054_rsablob.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "database/sql" 8 | "encoding/pem" 9 | "fmt" 10 | ) 11 | 12 | func rsablob(ctx context.Context, domain string, tx *sql.Tx) error { 13 | if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ADD COLUMN rsaprivkeyblob BLOB`); err != nil { 14 | return err 15 | } 16 | 17 | if rows, err := tx.QueryContext(ctx, `SELECT id, rsaprivkey FROM persons WHERE rsaprivkey IS NOT NULL`); err != nil { 18 | return err 19 | } else { 20 | defer rows.Close() 21 | 22 | for rows.Next() { 23 | var id, rsaPrivKeyPem string 24 | if err := rows.Scan(&id, &rsaPrivKeyPem); err != nil { 25 | return err 26 | } 27 | 28 | p, _ := pem.Decode([]byte(rsaPrivKeyPem)) 29 | 30 | privateKey, err := x509.ParsePKCS8PrivateKey(p.Bytes) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | rsaPrivateKey, ok := privateKey.(*rsa.PrivateKey) 36 | if !ok { 37 | return fmt.Errorf("wrong key type for %s", id) 38 | } 39 | 40 | if _, err := tx.ExecContext(ctx, `UPDATE persons SET rsaprivkeyblob = ? WHERE id = ?`, x509.MarshalPKCS1PrivateKey(rsaPrivateKey), id); err != nil { 41 | return err 42 | } 43 | } 44 | 45 | if err := rows.Err(); err != nil { 46 | return err 47 | } 48 | } 49 | 50 | if _, err := tx.ExecContext(ctx, `ALTER TABLE persons DROP COLUMN rsaprivkey`); err != nil { 51 | return err 52 | } 53 | 54 | if _, err := tx.ExecContext(ctx, `ALTER TABLE persons RENAME COLUMN rsaprivkeyblob TO rsaprivkey`); err != nil { 55 | return err 56 | } 57 | 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /ap/attachment.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | import ( 20 | "database/sql/driver" 21 | "encoding/json" 22 | "fmt" 23 | 24 | "github.com/dimkr/tootik/danger" 25 | ) 26 | 27 | type AttachmentType string 28 | 29 | const ( 30 | Image AttachmentType = "Image" 31 | PropertyValue AttachmentType = "PropertyValue" 32 | ) 33 | 34 | type Attachment struct { 35 | Type AttachmentType `json:"type,omitempty"` 36 | MediaType string `json:"mediaType,omitempty"` 37 | URL string `json:"url,omitempty"` 38 | Href string `json:"href,omitempty"` 39 | Name string `json:"name,omitempty"` 40 | Val string `json:"value,omitempty"` 41 | } 42 | 43 | func (a *Attachment) Scan(src any) error { 44 | switch v := src.(type) { 45 | case []byte: 46 | return json.Unmarshal(v, a) 47 | case string: 48 | return json.Unmarshal(danger.Bytes(v), a) 49 | default: 50 | return fmt.Errorf("unsupported conversion from %T to %T", src, a) 51 | } 52 | } 53 | 54 | func (a *Attachment) Value() (driver.Value, error) { 55 | return danger.MarshalJSON(a) 56 | } 57 | -------------------------------------------------------------------------------- /migrations/053_ed25519blob.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/dimkr/tootik/data" 8 | ) 9 | 10 | func ed25519blob(ctx context.Context, domain string, tx *sql.Tx) error { 11 | if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ADD COLUMN ed25519privkeyblob BLOB`); err != nil { 12 | return err 13 | } 14 | 15 | if rows, err := tx.QueryContext(ctx, `SELECT id, ed25519privkey FROM persons WHERE ed25519privkey IS NOT NULL`); err != nil { 16 | return err 17 | } else { 18 | defer rows.Close() 19 | 20 | for rows.Next() { 21 | var id, ed25519PrivKeyMultibase string 22 | if err := rows.Scan(&id, &ed25519PrivKeyMultibase); err != nil { 23 | return err 24 | } 25 | 26 | ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | if _, err := tx.ExecContext(ctx, `UPDATE persons SET ed25519privkeyblob = ? WHERE id = ?`, ed25519PrivKey.Seed(), id); err != nil { 32 | return err 33 | } 34 | } 35 | 36 | if err := rows.Err(); err != nil { 37 | return err 38 | } 39 | } 40 | 41 | if _, err := tx.ExecContext(ctx, `DROP INDEX personscidlocal`); err != nil { 42 | return err 43 | } 44 | 45 | if _, err := tx.ExecContext(ctx, `ALTER TABLE persons DROP COLUMN ed25519privkey`); err != nil { 46 | return err 47 | } 48 | 49 | if _, err := tx.ExecContext(ctx, `ALTER TABLE persons RENAME COLUMN ed25519privkeyblob TO ed25519privkey`); err != nil { 50 | return err 51 | } 52 | 53 | if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX personscidlocal ON persons(cid) WHERE ed25519privkey IS NOT NULL`); err != nil { 54 | return err 55 | } 56 | 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /test/communities_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package test 18 | 19 | import ( 20 | "fmt" 21 | "strings" 22 | "testing" 23 | "time" 24 | 25 | "github.com/stretchr/testify/assert" 26 | ) 27 | 28 | func TestCommunities_OneCommunity(t *testing.T) { 29 | server := newTestServer() 30 | defer server.Shutdown() 31 | 32 | assert := assert.New(t) 33 | 34 | _, err := server.db.Exec( 35 | `update persons set actor = jsonb_set(actor, '$.type', 'Group') where id = $1`, 36 | server.Alice.ID, 37 | ) 38 | assert.NoError(err) 39 | 40 | follow := server.Handle("/users/follow/"+strings.TrimPrefix(server.Alice.ID, "https://"), server.Bob) 41 | assert.Equal(fmt.Sprintf("30 /users/outbox/%s\r\n", strings.TrimPrefix(server.Alice.ID, "https://")), follow) 42 | 43 | say := server.Handle("/users/say?Hello%20%40alice%40localhost.localdomain%3a8443", server.Bob) 44 | assert.Regexp(`^30 /users/view/\S+\r\n$`, say) 45 | 46 | communities := server.Handle("/users/communities", server.Bob) 47 | assert.Contains(strings.Split(communities, "\n"), fmt.Sprintf("=> /users/outbox/%s/user/alice %s alice", domain, time.Now().Format(time.DateOnly))) 48 | } 49 | -------------------------------------------------------------------------------- /migrations/023_tocc.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func tocc(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `ALTER TABLE notes DROP COLUMN to0`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `ALTER TABLE notes DROP COLUMN to1`); err != nil { 14 | return err 15 | } 16 | 17 | if _, err := tx.ExecContext(ctx, `ALTER TABLE notes DROP COLUMN to2`); err != nil { 18 | return err 19 | } 20 | 21 | if _, err := tx.ExecContext(ctx, `ALTER TABLE notes DROP COLUMN cc0`); err != nil { 22 | return err 23 | } 24 | 25 | if _, err := tx.ExecContext(ctx, `ALTER TABLE notes DROP COLUMN cc1`); err != nil { 26 | return err 27 | } 28 | 29 | if _, err := tx.ExecContext(ctx, `ALTER TABLE notes DROP COLUMN cc2`); err != nil { 30 | return err 31 | } 32 | 33 | if _, err := tx.ExecContext(ctx, `ALTER TABLE notes ADD COLUMN to0 STRING AS (object->>'to[0]')`); err != nil { 34 | return err 35 | } 36 | 37 | if _, err := tx.ExecContext(ctx, `ALTER TABLE notes ADD COLUMN to1 STRING AS (object->>'to[1]')`); err != nil { 38 | return err 39 | } 40 | 41 | if _, err := tx.ExecContext(ctx, `ALTER TABLE notes ADD COLUMN to2 STRING AS (object->>'to[2]')`); err != nil { 42 | return err 43 | } 44 | 45 | if _, err := tx.ExecContext(ctx, `ALTER TABLE notes ADD COLUMN cc0 STRING AS (object->>'cc[0]')`); err != nil { 46 | return err 47 | } 48 | 49 | if _, err := tx.ExecContext(ctx, `ALTER TABLE notes ADD COLUMN cc1 STRING AS (object->>'cc[1]')`); err != nil { 50 | return err 51 | } 52 | 53 | _, err := tx.ExecContext(ctx, `ALTER TABLE notes ADD COLUMN cc2 STRING AS (object->>'cc[2]')`) 54 | return err 55 | } 56 | -------------------------------------------------------------------------------- /front/static/help.gmi: -------------------------------------------------------------------------------- 1 | # 🛟 Help 2 | 3 | ## About {{.Domain}} 4 | 5 | This server runs tootik, a text-based social network. 6 | => https://github.com/dimkr/tootik The tootik project 7 | 8 | ## Menu 9 | 10 | > 📡 Local feed 11 | 12 | This page shows public posts published on this server. 13 | 14 | > 🏕️ Communities 15 | 16 | This page shows communities on this server. 17 | 18 | > 🔥 Hashtags 19 | 20 | This page shows popular hashtags, allowing you to discover trends and shared interests. 21 | 22 | > 🔎 Search posts 23 | 24 | This is a full-text search tool that lists posts containing keyword(s), ordered by relevance. 25 | 26 | > 📊 Status 27 | 28 | This page shows various statistics about this server and the parts of the fediverse it's connected to. 29 | 30 | > 🔑 Sign in 31 | 32 | Follow this link to sign in or create an account on this server. 33 | 34 | Registered users can: 35 | * Publish posts 36 | * Reply to posts 37 | * Vote on polls 38 | * Follow users 39 | * View private posts 40 | * View a feed of posts by followed users 41 | * Bookmark posts 42 | * Invite other users 43 | 44 | If invitations are enabled, registration requires a valid invitation code generated by an existing user. 45 | 46 | The Common Name property of the client certificate ("identity") you use will determine your username. 47 | 48 | You can add additional client certificates later, or revoke access for an old certificate. 49 | 50 | Settings → Data portability allows one to view the private key used to prove the ownership over an account and configure the list of gateways. 51 | 52 | Only one account on this server can use a particular private key: if all client certificates associated with your account are lost or expired, you won't be able to register again with same key to restore access to your account. 53 | -------------------------------------------------------------------------------- /front/unfollow.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "database/sql" 21 | "errors" 22 | 23 | "github.com/dimkr/tootik/front/text" 24 | ) 25 | 26 | func (h *Handler) unfollow(w text.Writer, r *Request, args ...string) { 27 | if r.User == nil { 28 | w.Redirect("/users") 29 | return 30 | } 31 | 32 | followed := "https://" + args[1] 33 | 34 | var followID string 35 | if err := h.DB.QueryRowContext(r.Context, `select follows.id from persons join follows on persons.id = follows.followed where persons.id = ? and follows.follower = ?`, followed, r.User.ID).Scan(&followID); err != nil && errors.Is(err, sql.ErrNoRows) { 36 | r.Log.Warn("Cannot undo a non-existing follow", "followed", followed, "error", err) 37 | w.Status(40, "No such follow") 38 | return 39 | } else if err != nil { 40 | r.Log.Warn("Failed to find followed user", "followed", followed, "error", err) 41 | w.Error() 42 | return 43 | } 44 | 45 | if err := h.Inbox.Unfollow(r.Context, r.User, r.Keys[1], followed, followID); err != nil { 46 | r.Log.Warn("Failed undo follow", "followed", followed, "error", err) 47 | w.Error() 48 | return 49 | } 50 | 51 | w.Redirect("/users/outbox/" + args[1]) 52 | } 53 | -------------------------------------------------------------------------------- /icon/scale.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package icon 18 | 19 | import ( 20 | "bytes" 21 | "errors" 22 | "image" 23 | "image/draw" 24 | "image/gif" 25 | _ "image/jpeg" 26 | _ "image/png" 27 | 28 | "github.com/dimkr/tootik/cfg" 29 | xdraw "golang.org/x/image/draw" 30 | ) 31 | 32 | func Scale(cfg *cfg.Config, data []byte) ([]byte, error) { 33 | dim, _, err := image.DecodeConfig(bytes.NewReader(data)) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | if dim.Height > cfg.MaxAvatarHeight || dim.Width > cfg.MaxAvatarWidth { 39 | return nil, errors.New("too big") 40 | } 41 | 42 | im, _, err := image.Decode(bytes.NewReader(data)) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | if dim.Height > cfg.AvatarHeight || dim.Width > cfg.AvatarWidth { 48 | bounds := image.Rectangle{Min: image.Point{0, 0}, Max: image.Point{cfg.AvatarWidth, cfg.AvatarHeight}} 49 | scaled := image.NewRGBA(bounds) 50 | xdraw.NearestNeighbor.Scale(scaled, bounds, im, im.Bounds(), draw.Over, nil) 51 | im = scaled 52 | } 53 | 54 | var b bytes.Buffer 55 | if err := gif.Encode(&b, im, &gif.Options{NumColors: 256}); err != nil { 56 | return nil, err 57 | } 58 | 59 | return b.Bytes(), nil 60 | } 61 | -------------------------------------------------------------------------------- /front/approve.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import "github.com/dimkr/tootik/front/text" 20 | 21 | func (h *Handler) approve(w text.Writer, r *Request, args ...string) { 22 | if r.User == nil { 23 | w.Redirect("/users") 24 | return 25 | } 26 | 27 | hash := args[1] 28 | 29 | r.Log.Info("Approving certificate", "user", r.User.PreferredUsername, "hash", hash) 30 | 31 | if res, err := h.DB.ExecContext( 32 | r.Context, 33 | ` 34 | update certificates set approved = 1 35 | where user = ? and hash = ? and approved = 0 36 | `, 37 | r.User.PreferredUsername, 38 | hash, 39 | ); err != nil { 40 | r.Log.Warn("Failed to approve certificate", "user", r.User.PreferredUsername, "hash", hash, "error", err) 41 | w.Error() 42 | return 43 | } else if n, err := res.RowsAffected(); err != nil { 44 | r.Log.Warn("Failed to approve certificate", "user", r.User.PreferredUsername, "hash", hash, "error", err) 45 | w.Error() 46 | return 47 | } else if n == 0 { 48 | r.Log.Warn("Certificate doesn't exist or already approved", "user", r.User.PreferredUsername, "hash", hash) 49 | w.Status(40, "Cannot approve certificate") 50 | return 51 | } 52 | 53 | w.Redirect("/users/certificates") 54 | } 55 | -------------------------------------------------------------------------------- /front/hashtag.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "database/sql" 21 | 22 | "github.com/dimkr/tootik/front/text" 23 | ) 24 | 25 | func (h *Handler) hashtag(w text.Writer, r *Request, args ...string) { 26 | tag := args[1] 27 | 28 | h.showFeedPage( 29 | w, 30 | r, 31 | "Posts Tagged #"+tag, 32 | func(offset int) (*sql.Rows, error) { 33 | return h.DB.QueryContext( 34 | r.Context, 35 | `select json(notes.object), json(persons.actor), null, notes.inserted from notes join hashtags on notes.id = hashtags.note left join (select object->>'$.inReplyTo' as id, count(*) as count from notes where inserted >= unixepoch() - 7*24*60*60 group by object->>'$.inReplyTo') replies on notes.id = replies.id left join persons on notes.author = persons.id where notes.public = 1 and hashtags.hashtag = $1 order by replies.count desc, notes.inserted/(24*60*60) desc, notes.inserted desc limit $2 offset $3`, 36 | tag, 37 | h.Config.PostsPerPage, 38 | offset, 39 | ) 40 | }, 41 | false, 42 | ) 43 | 44 | w.Separator() 45 | 46 | if r.User == nil { 47 | w.Link("/search", "🔎 Posts by hashtag") 48 | } else { 49 | w.Link("/users/search", "🔎 Posts by hashtag") 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /front/unshare.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "database/sql" 21 | "errors" 22 | 23 | "github.com/dimkr/tootik/ap" 24 | "github.com/dimkr/tootik/front/text" 25 | ) 26 | 27 | func (h *Handler) unshare(w text.Writer, r *Request, args ...string) { 28 | if r.User == nil { 29 | w.Redirect("/users") 30 | return 31 | } 32 | 33 | postID := "https://" + args[1] 34 | 35 | var share ap.Activity 36 | if err := h.DB.QueryRowContext(r.Context, `select json(activity) from outbox where activity->>'$.actor' = $1 and sender = $1 and activity->>'$.type' = 'Announce' and activity->>'$.object' = $2`, r.User.ID, postID).Scan(&share); err != nil && errors.Is(err, sql.ErrNoRows) { 37 | r.Log.Warn("Attempted to unshare non-existing share", "post", postID, "error", err) 38 | w.Error() 39 | return 40 | } else if err != nil { 41 | r.Log.Warn("Failed to fetch share to unshare", "post", postID, "error", err) 42 | w.Error() 43 | return 44 | } 45 | 46 | if err := h.Inbox.Undo(r.Context, r.User, r.Keys[1], &share); err != nil { 47 | r.Log.Warn("Failed to unshare post", "post", postID, "error", err) 48 | w.Error() 49 | return 50 | } 51 | 52 | w.Redirectf("/users/view/" + args[1]) 53 | } 54 | -------------------------------------------------------------------------------- /front/delete.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "database/sql" 21 | "errors" 22 | "strings" 23 | 24 | "github.com/dimkr/tootik/ap" 25 | "github.com/dimkr/tootik/front/text" 26 | ) 27 | 28 | func (h *Handler) delete(w text.Writer, r *Request, args ...string) { 29 | if r.User == nil { 30 | w.Redirect("/users") 31 | return 32 | } 33 | 34 | postID := "https://" + args[1] 35 | 36 | var note ap.Object 37 | if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where id = ? and author in (select id from persons where cid = ?)`, postID, ap.Canonical(r.User.ID)).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { 38 | r.Log.Warn("Attempted to delete a non-existing post", "post", postID, "error", err) 39 | w.Error() 40 | return 41 | } else if err != nil { 42 | r.Log.Warn("Failed to fetch post to delete", "post", postID, "error", err) 43 | w.Error() 44 | return 45 | } 46 | 47 | if err := h.Inbox.Delete(r.Context, r.User, r.Keys[1], ¬e); err != nil { 48 | r.Log.Error("Failed to delete post", "note", note.ID, "error", err) 49 | w.Error() 50 | return 51 | } 52 | 53 | w.Redirect("/users/outbox/" + strings.TrimPrefix(r.User.ID, "https://")) 54 | } 55 | -------------------------------------------------------------------------------- /httpsig/string.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package httpsig 18 | 19 | import ( 20 | "errors" 21 | "net/http" 22 | "net/textproto" 23 | "strings" 24 | ) 25 | 26 | func buildSignatureString(r *http.Request, headers []string) (string, error) { 27 | var b strings.Builder 28 | 29 | for i, h := range headers { 30 | switch h { 31 | case "(request-target)": 32 | b.WriteString("(request-target)") 33 | b.WriteByte(':') 34 | b.WriteByte(' ') 35 | b.WriteString(strings.ToLower(r.Method)) 36 | b.WriteByte(' ') 37 | b.WriteString(r.URL.Path) 38 | 39 | default: 40 | if h[0] == '(' { 41 | return "", errors.New("unsupported header: " + h) 42 | } 43 | b.WriteString(strings.ToLower(h)) 44 | b.WriteByte(':') 45 | b.WriteByte(' ') 46 | values, ok := r.Header[textproto.CanonicalMIMEHeaderKey(h)] 47 | if !ok || len(values) == 0 { 48 | return "", errors.New("unspecified header: " + h) 49 | } 50 | for j, v := range values { 51 | b.WriteString(strings.TrimSpace(v)) 52 | if j < len(values)-1 { 53 | b.WriteByte(',') 54 | b.WriteByte(' ') 55 | } 56 | } 57 | } 58 | 59 | if i < len(headers)-1 { 60 | b.WriteByte('\n') 61 | } 62 | } 63 | 64 | return b.String(), nil 65 | } 66 | -------------------------------------------------------------------------------- /fed/post.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package fed 18 | 19 | import ( 20 | "database/sql" 21 | "errors" 22 | "fmt" 23 | "log/slog" 24 | "net/http" 25 | 26 | "github.com/dimkr/tootik/danger" 27 | ) 28 | 29 | func (l *Listener) handlePost(w http.ResponseWriter, r *http.Request) { 30 | postID := fmt.Sprintf("https://%s/post/%s", l.Domain, r.PathValue("hash")) 31 | 32 | if shouldRedirect(r) { 33 | url := fmt.Sprintf("gemini://%s/view/%s%s", l.Domain, l.Domain, r.URL.Path) 34 | slog.Info("Redirecting to post over Gemini", "url", url) 35 | w.Header().Set("Location", url) 36 | w.WriteHeader(http.StatusMovedPermanently) 37 | return 38 | } 39 | 40 | slog.Info("Fetching post", "post", postID) 41 | 42 | var note string 43 | if err := l.DB.QueryRowContext(r.Context(), `select json(object) from notes where id = ? and public = 1`, postID).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { 44 | w.WriteHeader(http.StatusNotFound) 45 | return 46 | } else if err != nil { 47 | slog.Warn("Failed to fetch post", "post", postID, "error", err) 48 | w.WriteHeader(http.StatusInternalServerError) 49 | return 50 | } 51 | 52 | w.Header().Set("Content-Type", "application/activity+json; charset=utf-8") 53 | w.Write(danger.Bytes(note)) 54 | } 55 | -------------------------------------------------------------------------------- /front/local.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "database/sql" 21 | 22 | "github.com/dimkr/tootik/front/text" 23 | ) 24 | 25 | func (h *Handler) local(w text.Writer, r *Request, args ...string) { 26 | h.showFeedPage( 27 | w, 28 | r, 29 | "📡 Local Feed", 30 | func(offset int) (*sql.Rows, error) { 31 | return h.DB.QueryContext( 32 | r.Context, 33 | ` 34 | select json(object), json(actor), json(sharer), inserted from 35 | ( 36 | select notes.object, persons.actor, null as sharer, notes.inserted from persons 37 | join notes 38 | on notes.author = persons.id 39 | where notes.public = 1 and persons.host = $1 40 | union all 41 | select notes.object, persons.actor, sharers.actor as sharer, shares.inserted from persons sharers 42 | join shares 43 | on shares.by = sharers.id 44 | join notes 45 | on notes.id = shares.note 46 | join persons 47 | on persons.id = notes.author 48 | where notes.public = 1 and sharers.host = $1 49 | ) 50 | order by inserted desc 51 | limit $2 52 | offset $3 53 | `, 54 | h.Domain, 55 | h.Config.PostsPerPage, 56 | offset, 57 | ) 58 | }, 59 | true, 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /front/revoke.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import "github.com/dimkr/tootik/front/text" 20 | 21 | func (h *Handler) revoke(w text.Writer, r *Request, args ...string) { 22 | if r.User == nil { 23 | w.Redirect("/users") 24 | return 25 | } 26 | 27 | hash := args[1] 28 | 29 | r.Log.Info("Revoking certificate", "user", r.User.PreferredUsername, "hash", hash) 30 | 31 | if res, err := h.DB.ExecContext( 32 | r.Context, 33 | ` 34 | delete from certificates 35 | where user = $1 and hash = $2 and exists (select 1 from certificates others where others.user = $1 and others.hash != $2 and others.approved = 1) 36 | `, 37 | r.User.PreferredUsername, 38 | hash, 39 | ); err != nil { 40 | r.Log.Warn("Failed to revoke certificate", "user", r.User.PreferredUsername, "hash", hash, "error", err) 41 | w.Error() 42 | return 43 | } else if n, err := res.RowsAffected(); err != nil { 44 | r.Log.Warn("Failed to revoke certificate", "user", r.User.PreferredUsername, "hash", hash, "error", err) 45 | w.Error() 46 | return 47 | } else if n == 0 { 48 | r.Log.Warn("Certificate doesn't exist or already revoked", "user", r.User.PreferredUsername, "hash", hash) 49 | w.Status(40, "Cannot revoke certificate") 50 | return 51 | } 52 | 53 | w.Redirect("/users/certificates") 54 | } 55 | -------------------------------------------------------------------------------- /fed/pprof.go: -------------------------------------------------------------------------------- 1 | //go:build !no_pprof 2 | 3 | /* 4 | Copyright 2025 Dima Krasner 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | package fed 20 | 21 | import ( 22 | "log/slog" 23 | "math/rand/v2" 24 | "net/http" 25 | "net/http/pprof" 26 | "strings" 27 | ) 28 | 29 | func withoutPrefix(h func(http.ResponseWriter, *http.Request), prefix string) func(http.ResponseWriter, *http.Request) { 30 | return func(w http.ResponseWriter, r *http.Request) { 31 | r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix) 32 | h(w, r) 33 | } 34 | } 35 | 36 | func generatePrefix() string { 37 | b := make([]byte, 33) 38 | 39 | b[0] = '/' 40 | for i := 1; i < 33; i++ { 41 | b[i] = 'a' + byte(rand.IntN('z'-'a')) 42 | } 43 | 44 | return string(b) 45 | } 46 | 47 | func (l *Listener) withPprof(inner http.Handler) (http.Handler, error) { 48 | mux := http.NewServeMux() 49 | mux.Handle("/", inner) 50 | 51 | prefix := generatePrefix() 52 | slog.Info("Enabling pprof", "url", "https://"+l.Domain+prefix+"/debug/pprof") 53 | 54 | mux.HandleFunc("GET "+prefix+"/debug/pprof/", withoutPrefix(pprof.Index, prefix)) 55 | mux.HandleFunc("GET "+prefix+"/debug/pprof/cmdline", pprof.Cmdline) 56 | mux.HandleFunc("GET "+prefix+"/debug/pprof/profile", pprof.Profile) 57 | mux.HandleFunc("GET "+prefix+"/debug/pprof/symbol", pprof.Symbol) 58 | mux.HandleFunc("GET "+prefix+"/debug/pprof/trace", pprof.Trace) 59 | 60 | return mux, nil 61 | } 62 | -------------------------------------------------------------------------------- /icon/generate.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023, 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package icon 18 | 19 | import ( 20 | "bytes" 21 | "crypto/sha256" 22 | "image" 23 | "image/color" 24 | "image/draw" 25 | "image/gif" 26 | 27 | "github.com/dimkr/tootik/danger" 28 | ) 29 | 30 | // Generate generates a tiny pseudo-random image by user name 31 | func Generate(s string) ([]byte, error) { 32 | hash := sha256.Sum256(danger.Bytes(s)) 33 | 34 | fg := color.RGBA{128 + (hash[0]^hash[29])%128, 128 + (hash[1]^hash[30])%128, 128 + (hash[2]^hash[31])%128, 255} 35 | alt := []color.RGBA{ 36 | {fg.R, fg.B, fg.G, 255}, 37 | {fg.G, fg.B, fg.R, 255}, 38 | {fg.G, fg.R, fg.B, 255}, 39 | {fg.B, fg.R, fg.G, 255}, 40 | {fg.B, fg.G, fg.R, 255}, 41 | }[hash[0]%5] 42 | bg := color.RGBA{255 - fg.R, 255 - fg.G, 255 - fg.B, 255} 43 | 44 | m := image.NewPaletted(image.Rect(0, 0, 8, 8), color.Palette{bg, fg, alt}) 45 | draw.Draw(m, m.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src) 46 | 47 | for i, b := range hash[:16] { 48 | c := fg 49 | if hash[16+i]%8 == 0 { 50 | c = alt 51 | } 52 | 53 | if (b^hash[16+i])%2 == 1 { 54 | m.Set(i%4, i/4, c) 55 | m.Set(i%4, 7-i/4, c) 56 | m.Set(7-i%4, 7-i/4, c) 57 | m.Set(7-i%4, i/4, c) 58 | } 59 | } 60 | 61 | var buf bytes.Buffer 62 | if err := gif.Encode(&buf, m, &gif.Options{NumColors: 3}); err != nil { 63 | return nil, err 64 | } 65 | 66 | return buf.Bytes(), nil 67 | } 68 | -------------------------------------------------------------------------------- /migrations/044_keys.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | func keys(ctx context.Context, domain string, tx *sql.Tx) error { 9 | if _, err := tx.ExecContext(ctx, `CREATE TABLE keys(id TEXT PRIMARY KEY, actor TEXT NOT NULL)`); err != nil { 10 | return err 11 | } 12 | 13 | if _, err := tx.ExecContext(ctx, `INSERT INTO keys(actor, id) SELECT persons.id, actor->>'$.publicKey.id' FROM persons WHERE actor->>'$.publicKey.id' IS NOT NULL AND host != ? AND actor->>'$.publicKey.id' LIKE 'https://' || host || '/%'`, domain); err != nil { 14 | return err 15 | } 16 | 17 | if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO keys(actor, id) SELECT persons.id, actor->>'$.assertionMethod[0].id' FROM persons WHERE actor->>'$.assertionMethod[0].id' IS NOT NULL AND actor->>'$.assertionMethod[0].type' = 'Multikey' AND host != ? AND actor->>'$.assertionMethod[0].id' LIKE 'https://' || host || '/%'`, domain); err != nil { 18 | return err 19 | } 20 | 21 | if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO keys(actor, id) SELECT persons.id, actor->>'$.assertionMethod[1].id' FROM persons WHERE actor->>'$.assertionMethod[1].id' IS NOT NULL AND actor->>'$.assertionMethod[1].type' = 'Multikey' AND host != ? AND actor->>'$.assertionMethod[1].id' LIKE 'https://' || host || '/%'`, domain); err != nil { 22 | return err 23 | } 24 | 25 | if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO keys(actor, id) SELECT persons.id, actor->>'$.assertionMethod[2].id' FROM persons WHERE actor->>'$.assertionMethod[2].id' IS NOT NULL AND actor->>'$.assertionMethod[2].type' = 'Multikey' AND host != ? AND actor->>'$.assertionMethod[2].id' LIKE 'https://' || host || '/%'`, domain); err != nil { 26 | return err 27 | } 28 | 29 | if _, err := tx.ExecContext(ctx, `DROP INDEX personspublickeyid`); err != nil { 30 | return err 31 | } 32 | 33 | if _, err := tx.ExecContext(ctx, `CREATE INDEX keysactor ON keys(actor)`); err != nil { 34 | return err 35 | } 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /cluster/client.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package cluster 18 | 19 | import ( 20 | "bytes" 21 | "fmt" 22 | "io" 23 | "net/http" 24 | ) 25 | 26 | // Client is a [fed.Client] that handles a HTTP request by calling the destination server's [http.Handler]. 27 | type Client map[string]*Server 28 | 29 | type responseWriter struct { 30 | StatusCode int 31 | Headers http.Header 32 | Body bytes.Buffer 33 | } 34 | 35 | func (w *responseWriter) Header() http.Header { 36 | return w.Headers 37 | } 38 | 39 | func (w *responseWriter) Write(buf []byte) (int, error) { 40 | if w.StatusCode == 0 { 41 | w.StatusCode = http.StatusOK 42 | } 43 | 44 | return w.Body.Write(buf) 45 | } 46 | 47 | func (w *responseWriter) WriteHeader(statusCode int) { 48 | w.StatusCode = statusCode 49 | } 50 | 51 | func (f Client) Do(r *http.Request) (*http.Response, error) { 52 | dst := f[r.URL.Host] 53 | if dst == nil { 54 | return nil, fmt.Errorf("unknown server: %s", r.URL.Host) 55 | } 56 | 57 | // empty fields that are normally empty in server side 58 | clone := *r 59 | clone.URL.Host = "" 60 | clone.URL.Scheme = "" 61 | 62 | w := responseWriter{ 63 | Headers: http.Header{}, 64 | } 65 | dst.Backend.ServeHTTP(&w, &clone) 66 | 67 | return &http.Response{ 68 | StatusCode: w.StatusCode, 69 | Header: w.Headers, 70 | Body: io.NopCloser(bytes.NewReader(w.Body.Bytes())), 71 | ContentLength: int64(w.Body.Len()), 72 | }, nil 73 | } 74 | -------------------------------------------------------------------------------- /migrations/049_pembegin.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/x509" 7 | "database/sql" 8 | "encoding/pem" 9 | 10 | "github.com/dimkr/tootik/ap" 11 | "github.com/dimkr/tootik/data" 12 | "github.com/dimkr/tootik/httpsig" 13 | "github.com/dimkr/tootik/proof" 14 | ) 15 | 16 | func pembegin(ctx context.Context, domain string, tx *sql.Tx) error { 17 | if rows, err := tx.QueryContext(ctx, `SELECT JSON(actor), ed25519privkey FROM persons WHERE ed25519privkey IS NOT NULL AND actor->>'$.publicKey.publicKeyPem' LIKE '%BEGIN RSA PUBLIC KEY%'`); err != nil { 18 | return err 19 | } else { 20 | defer rows.Close() 21 | 22 | for rows.Next() { 23 | var actor ap.Actor 24 | var ed25519PrivKeyMultibase string 25 | if err := rows.Scan(&actor, &ed25519PrivKeyMultibase); err != nil { 26 | return err 27 | } 28 | 29 | ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | publicKeyPem, _ := pem.Decode([]byte(actor.PublicKey.PublicKeyPem)) 35 | 36 | publicKey, err := x509.ParsePKCS1PublicKey(publicKeyPem.Bytes) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | der, err := x509.MarshalPKIXPublicKey(publicKey) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | var pubPem bytes.Buffer 47 | if err := pem.Encode( 48 | &pubPem, 49 | &pem.Block{ 50 | Type: "PUBLIC KEY", 51 | Bytes: der, 52 | }, 53 | ); err != nil { 54 | return err 55 | } 56 | 57 | actor.PublicKey.PublicKeyPem = pubPem.String() 58 | 59 | actor.Proof, err = proof.Create(httpsig.Key{ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519PrivKey}, &actor) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | if _, err := tx.ExecContext(ctx, `UPDATE persons SET actor = JSONB(?) WHERE id = ?`, &actor, actor.ID); err != nil { 65 | return err 66 | } 67 | } 68 | 69 | if err := rows.Err(); err != nil { 70 | return err 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /migrations/055_iconscid.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "crypto/ed25519" 6 | "database/sql" 7 | "fmt" 8 | 9 | "github.com/dimkr/tootik/ap" 10 | "github.com/dimkr/tootik/httpsig" 11 | "github.com/dimkr/tootik/icon" 12 | "github.com/dimkr/tootik/proof" 13 | ) 14 | 15 | func iconscid(ctx context.Context, domain string, tx *sql.Tx) error { 16 | if rows, err := tx.QueryContext(ctx, `SELECT JSON(actor), ed25519privkey FROM persons WHERE ed25519privkey IS NOT NULL AND id LIKE 'https://' || ? || '/.well-known/apgateway/did:key:%'`, domain); err != nil { 17 | return err 18 | } else { 19 | defer rows.Close() 20 | 21 | for rows.Next() { 22 | var actor ap.Actor 23 | var ed25519PrivKey []byte 24 | if err := rows.Scan(&actor, &ed25519PrivKey); err != nil { 25 | return err 26 | } 27 | 28 | actor.Icon = []ap.Attachment{ 29 | { 30 | Type: ap.Image, 31 | MediaType: icon.MediaType, 32 | URL: fmt.Sprintf("%s/icon%s", actor.ID, icon.FileNameExtension), 33 | }, 34 | } 35 | 36 | actor.Proof, err = proof.Create(httpsig.Key{ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, &actor) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | if _, err := tx.ExecContext(ctx, `UPDATE persons SET actor = JSONB(?) WHERE id = ?`, &actor, actor.ID); err != nil { 42 | return err 43 | } 44 | } 45 | 46 | if err := rows.Err(); err != nil { 47 | return err 48 | } 49 | } 50 | 51 | if _, err := tx.ExecContext(ctx, `CREATE TABLE nicons(cid TEXT NOT NULL PRIMARY KEY, buf BLOB NOT NULL)`); err != nil { 52 | return err 53 | } 54 | 55 | if _, err := tx.ExecContext(ctx, `INSERT INTO nicons(cid, buf) SELECT persons.cid, icons.buf FROM icons JOIN persons ON persons.actor->>'$.preferredUsername' = icons.name AND persons.ed25519privkey IS NOT NULL`); err != nil { 56 | return err 57 | } 58 | 59 | if _, err := tx.ExecContext(ctx, `DROP TABLE icons`); err != nil { 60 | return err 61 | } 62 | 63 | _, err := tx.ExecContext(ctx, `ALTER TABLE nicons RENAME TO icons`) 64 | return err 65 | } 66 | -------------------------------------------------------------------------------- /front/user/app.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package user 18 | 19 | import ( 20 | "context" 21 | "crypto/ed25519" 22 | "crypto/x509" 23 | "database/sql" 24 | "errors" 25 | "fmt" 26 | 27 | "github.com/dimkr/tootik/ap" 28 | "github.com/dimkr/tootik/cfg" 29 | "github.com/dimkr/tootik/httpsig" 30 | ) 31 | 32 | // CreateApplicationActor creates the special "actor" user. 33 | // This user is used to sign outgoing requests not initiated by a particular user. 34 | func CreateApplicationActor(ctx context.Context, domain string, db *sql.DB, cfg *cfg.Config) (*ap.Actor, [2]httpsig.Key, error) { 35 | var actor ap.Actor 36 | var rsaPrivKeyDer, ed25519PrivKey []byte 37 | if err := db.QueryRowContext( 38 | ctx, 39 | `select json(actor), rsaprivkey, ed25519privkey from persons where actor->>'$.preferredUsername' = 'actor' and host = ?`, 40 | domain, 41 | ).Scan( 42 | &actor, 43 | &rsaPrivKeyDer, 44 | &ed25519PrivKey, 45 | ); errors.Is(err, sql.ErrNoRows) { 46 | return CreatePortable(ctx, domain, db, cfg, "actor", ap.Application, nil) 47 | } else if err != nil { 48 | return nil, [2]httpsig.Key{}, fmt.Errorf("failed to fetch application actor: %w", err) 49 | } 50 | 51 | rsaPrivKey, err := x509.ParsePKCS1PrivateKey(rsaPrivKeyDer) 52 | if err != nil { 53 | return nil, [2]httpsig.Key{}, err 54 | } 55 | 56 | return &actor, [2]httpsig.Key{ 57 | {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, 58 | {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, 59 | }, err 60 | } 61 | -------------------------------------------------------------------------------- /front/reject.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "database/sql" 21 | "errors" 22 | 23 | "github.com/dimkr/tootik/front/text" 24 | ) 25 | 26 | func (h *Handler) reject(w text.Writer, r *Request, args ...string) { 27 | if r.User == nil { 28 | w.Redirect("/users") 29 | return 30 | } 31 | 32 | follower := "https://" + args[1] 33 | 34 | tx, err := h.DB.BeginTx(r.Context, nil) 35 | if err != nil { 36 | r.Log.Warn("Failed to reject follow request", "follower", follower, "error", err) 37 | w.Error() 38 | return 39 | } 40 | defer tx.Rollback() 41 | 42 | var followID string 43 | if err := tx.QueryRowContext( 44 | r.Context, 45 | `SELECT id FROM follows WHERE follower = ? AND followed = ?`, 46 | follower, 47 | r.User.ID, 48 | ).Scan(&followID); errors.Is(err, sql.ErrNoRows) { 49 | r.Log.Warn("Failed to fetch follow request to reject", "follower", follower) 50 | w.Status(40, "No such follow request") 51 | return 52 | } else if err != nil { 53 | r.Log.Warn("Failed to reject follow request", "follower", follower, "error", err) 54 | w.Error() 55 | return 56 | } 57 | 58 | if err := h.Inbox.Reject(r.Context, r.User, r.Keys[1], follower, followID, tx); err != nil { 59 | r.Log.Warn("Failed to reject follow request", "follower", follower, "error", err) 60 | w.Error() 61 | return 62 | } 63 | 64 | if err := tx.Commit(); err != nil { 65 | r.Log.Warn("Failed to reject follow request", "follower", follower, "error", err) 66 | w.Error() 67 | return 68 | } 69 | 70 | w.Redirect("/users/followers") 71 | } 72 | -------------------------------------------------------------------------------- /ap/inbox.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | import ( 20 | "context" 21 | "database/sql" 22 | 23 | "github.com/dimkr/tootik/cfg" 24 | "github.com/dimkr/tootik/httpsig" 25 | ) 26 | 27 | // Inbox creates and processes activities. 28 | type Inbox interface { 29 | NewID(actorID, prefix string) (string, error) 30 | Accept(ctx context.Context, followed *Actor, key httpsig.Key, follower, followID string, tx *sql.Tx) error 31 | Announce(ctx context.Context, tx *sql.Tx, actor *Actor, key httpsig.Key, note *Object) error 32 | Create(ctx context.Context, cfg *cfg.Config, post *Object, author *Actor, key httpsig.Key) error 33 | Delete(ctx context.Context, actor *Actor, key httpsig.Key, note *Object) error 34 | Follow(ctx context.Context, follower *Actor, key httpsig.Key, followed string) error 35 | Move(ctx context.Context, from *Actor, key httpsig.Key, to string) error 36 | Reject(ctx context.Context, followed *Actor, key httpsig.Key, follower, followID string, tx *sql.Tx) error 37 | Undo(ctx context.Context, actor *Actor, key httpsig.Key, activity *Activity) error 38 | UpdateActorTx(ctx context.Context, tx *sql.Tx, actor *Actor, key httpsig.Key) error 39 | UpdateActor(ctx context.Context, actor *Actor, key httpsig.Key) error 40 | UpdateNote(ctx context.Context, actor *Actor, key httpsig.Key, note *Object) error 41 | Unfollow(ctx context.Context, follower *Actor, key httpsig.Key, followed, followID string) error 42 | ProcessActivity(ctx context.Context, tx *sql.Tx, sender *Actor, activity *Activity, rawActivity string, depth int, shared bool) error 43 | } 44 | -------------------------------------------------------------------------------- /cluster/quote_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package cluster 18 | 19 | import ( 20 | "strings" 21 | "testing" 22 | ) 23 | 24 | func TestCluster_PublicPostQuote(t *testing.T) { 25 | cluster := NewCluster(t, "a.localdomain", "b.localdomain") 26 | defer cluster.Stop() 27 | 28 | alice := cluster["a.localdomain"].Register(aliceKeypair).OK() 29 | bob := cluster["b.localdomain"].Register(bobKeypair).OK() 30 | 31 | alice. 32 | FollowInput("🔭 View profile", "bob@b.localdomain"). 33 | Follow("⚡ Follow bob"). 34 | OK() 35 | bob. 36 | FollowInput("🔭 View profile", "alice@a.localdomain"). 37 | Follow("⚡ Follow alice"). 38 | OK() 39 | cluster.Settle(t) 40 | 41 | post := bob. 42 | Follow("📣 New post"). 43 | FollowInput("📣 Anyone", "hello"). 44 | Contains(Line{Type: Quote, Text: "hello"}) 45 | cluster.Settle(t) 46 | 47 | profile := alice. 48 | FollowInput("🔭 View profile", "bob@b.localdomain"). 49 | Contains(Line{Type: Quote, Text: "hello"}) 50 | 51 | quoted := false 52 | for desc, url := range profile.Links { 53 | if strings.HasPrefix(url, "/users/view/") { 54 | profile. 55 | Follow(desc). 56 | FollowInput("♻️ Quote", "hola"). 57 | Contains(Line{Type: Quote, Text: "hola"}). 58 | Contains(Line{Type: Quote, Text: "hello"}) 59 | 60 | quoted = true 61 | break 62 | } 63 | } 64 | 65 | if !quoted { 66 | t.Fatal("Post not found") 67 | } 68 | 69 | cluster.Settle(t) 70 | 71 | post. 72 | Refresh(). 73 | Follow("♻️ alice"). 74 | Contains(Line{Type: Quote, Text: "hola"}). 75 | Contains(Line{Type: Quote, Text: "hello"}) 76 | } 77 | -------------------------------------------------------------------------------- /front/resolve.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "net/url" 21 | "regexp" 22 | "strings" 23 | 24 | "github.com/dimkr/tootik/ap" 25 | "github.com/dimkr/tootik/front/text" 26 | ) 27 | 28 | var resolveInputRegex = regexp.MustCompile(`^(\!{0,1})([^@]+)(?:@([^.@]+\.[^@]+)){0,1}$`) 29 | 30 | func (h *Handler) resolve(w text.Writer, r *Request, args ...string) { 31 | if r.User == nil { 32 | w.Redirect("/users") 33 | return 34 | } 35 | 36 | if r.URL.RawQuery == "" { 37 | w.Status(10, "User name (name, name@domain or !group@domain)") 38 | return 39 | } 40 | 41 | query, err := url.QueryUnescape(r.URL.RawQuery) 42 | if err != nil { 43 | r.Log.Info("Failed to decode user name", "url", r.URL, "error", err) 44 | w.Status(40, "Bad input") 45 | return 46 | } 47 | 48 | match := resolveInputRegex.FindStringSubmatch(query) 49 | if match == nil { 50 | w.Status(40, "Bad input") 51 | return 52 | } 53 | 54 | var flags ap.ResolverFlag 55 | if match[1] == "!" { 56 | flags |= ap.GroupActor 57 | } 58 | 59 | name := match[2] 60 | 61 | host := match[3] 62 | if host == "" { 63 | host = h.Domain 64 | } 65 | 66 | r.Log.Info("Resolving user ID", "host", host, "name", name) 67 | 68 | person, err := h.Resolver.Resolve(r.Context, r.Keys, host, name, flags) 69 | if err != nil { 70 | r.Log.Warn("Failed to resolve user ID", "host", host, "name", name, "error", err) 71 | w.Statusf(40, "Failed to resolve %s@%s", name, host) 72 | return 73 | } 74 | 75 | w.Redirect("/users/outbox/" + strings.TrimPrefix(person.ID, "https://")) 76 | } 77 | -------------------------------------------------------------------------------- /front/accept.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "database/sql" 21 | "errors" 22 | 23 | "github.com/dimkr/tootik/front/text" 24 | ) 25 | 26 | func (h *Handler) accept(w text.Writer, r *Request, args ...string) { 27 | if r.User == nil { 28 | w.Redirect("/users") 29 | return 30 | } 31 | 32 | follower := "https://" + args[1] 33 | 34 | tx, err := h.DB.BeginTx(r.Context, nil) 35 | if err != nil { 36 | r.Log.Warn("Failed to accept follow request", "follower", follower, "error", err) 37 | w.Error() 38 | return 39 | } 40 | defer tx.Rollback() 41 | 42 | var followID string 43 | if err := tx.QueryRowContext( 44 | r.Context, 45 | `SELECT id FROM follows WHERE followed = ? AND follower = ? AND accepted IS NULL`, 46 | r.User.ID, 47 | follower, 48 | ).Scan(&followID); errors.Is(err, sql.ErrNoRows) { 49 | r.Log.Warn("Failed to fetch follow request to approve", "follower", follower) 50 | w.Status(40, "No such follow request") 51 | return 52 | } else if err != nil { 53 | r.Log.Warn("Failed to accept follow request", "follower", follower, "error", err) 54 | w.Error() 55 | return 56 | } 57 | 58 | if err := h.Inbox.Accept(r.Context, r.User, r.Keys[1], follower, followID, tx); err != nil { 59 | r.Log.Warn("Failed to accept follow request", "follower", follower, "error", err) 60 | w.Error() 61 | return 62 | } 63 | 64 | if err := tx.Commit(); err != nil { 65 | r.Log.Warn("Failed to accept follow request", "follower", follower, "error", err) 66 | w.Error() 67 | return 68 | } 69 | 70 | w.Redirect("/users/followers") 71 | } 72 | -------------------------------------------------------------------------------- /front/menu.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023, 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "github.com/dimkr/tootik/ap" 21 | "github.com/dimkr/tootik/front/text" 22 | ) 23 | 24 | func writeUserMenu(w text.Writer, user *ap.Actor) { 25 | w.Empty() 26 | w.Subtitle("Menu") 27 | 28 | prefix := "" 29 | if user != nil { 30 | prefix = "/users" 31 | } 32 | 33 | if user != nil { 34 | w.Link("/users", "📻 My feed") 35 | w.Link("/users/mentions", "📞 Mentions") 36 | w.Link("/users/follows", "⚡️ Follows") 37 | w.Link("/users/followers", "🐕 Followers") 38 | w.Link("/users/me", "😈 My profile") 39 | } 40 | 41 | w.Link(prefix+"/local", "📡 Local feed") 42 | 43 | if user == nil { 44 | w.Link("/communities", "🏕️ Communities") 45 | w.Link("/hashtags", "🔥 Hashtags") 46 | w.Link("/fts", "🔎 Search posts") 47 | } else { 48 | w.Link("/users/communities", "🏕️ Communities") 49 | w.Link("/users/hashtags", "🔥 Hashtags") 50 | w.Link("/users/resolve", "🔭 View profile") 51 | w.Link("/users/bookmarks", "🔖 Bookmarks") 52 | w.Link("/users/fts", "🔎 Search posts") 53 | } 54 | 55 | if user == nil { 56 | w.Link("/users", "🔑 Sign in") 57 | } else { 58 | w.Link("/users/post", "📣 New post") 59 | w.Link("/users/settings", "⚙️ Settings") 60 | } 61 | 62 | w.Link(prefix+"/status", "📊 Status") 63 | w.Link(prefix+"/help", "🛟 Help") 64 | } 65 | 66 | func withUserMenu(f func(text.Writer, *Request, ...string)) func(text.Writer, *Request, ...string) { 67 | return func(w text.Writer, r *Request, args ...string) { 68 | f(w, r, args...) 69 | writeUserMenu(w, r.User) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /front/certificates.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "time" 21 | 22 | "github.com/dimkr/tootik/front/text" 23 | ) 24 | 25 | func (h *Handler) certificates(w text.Writer, r *Request, args ...string) { 26 | if r.User == nil { 27 | w.Redirect("/users") 28 | return 29 | } 30 | 31 | rows, err := h.DB.QueryContext( 32 | r.Context, 33 | ` 34 | select inserted, hash, approved, expires from certificates 35 | where user = ? 36 | order by inserted 37 | `, 38 | r.User.PreferredUsername, 39 | ) 40 | if err != nil { 41 | r.Log.Warn("Failed to fetch certificates", "user", r.User.PreferredUsername, "error", err) 42 | w.Error() 43 | return 44 | } 45 | 46 | defer rows.Close() 47 | 48 | w.OK() 49 | w.Title("🎓 Certificates") 50 | 51 | first := true 52 | for rows.Next() { 53 | var inserted, expires int64 54 | var hash string 55 | var approved int 56 | if err := rows.Scan(&inserted, &hash, &approved, &expires); err != nil { 57 | r.Log.Warn("Failed to fetch certificate", "user", r.User.PreferredUsername, "error", err) 58 | continue 59 | } 60 | 61 | if !first { 62 | w.Empty() 63 | } 64 | 65 | w.Item("SHA-256: " + hash) 66 | w.Item("Added: " + time.Unix(inserted, 0).Format(time.DateOnly)) 67 | w.Item("Expires: " + time.Unix(expires, 0).Format(time.DateOnly)) 68 | 69 | if approved == 0 { 70 | w.Link("/users/certificates/approve/"+hash, "🟢 Approve") 71 | w.Link("/users/certificates/revoke/"+hash, "🔴 Deny") 72 | } else { 73 | w.Link("/users/certificates/revoke/"+hash, "🔴 Revoke") 74 | } 75 | 76 | first = false 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /fed/user.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package fed 18 | 19 | import ( 20 | "database/sql" 21 | "errors" 22 | "fmt" 23 | "log/slog" 24 | "net/http" 25 | "strings" 26 | 27 | "github.com/dimkr/tootik/danger" 28 | ) 29 | 30 | func (l *Listener) doHandleUser(w http.ResponseWriter, r *http.Request, name string) { 31 | slog.Info("Looking up user", "name", name) 32 | 33 | var actorID, actorString string 34 | if err := l.DB.QueryRowContext(r.Context(), `select id, json(actor) from persons where actor->>'$.preferredUsername' = ? and host = ?`, name, l.Domain).Scan(&actorID, &actorString); err != nil && errors.Is(err, sql.ErrNoRows) { 35 | slog.Info("Notifying about deleted user", "name", name) 36 | w.WriteHeader(http.StatusNotFound) 37 | return 38 | } else if err != nil { 39 | w.WriteHeader(http.StatusInternalServerError) 40 | return 41 | } 42 | 43 | // redirect browsers to the outbox page over Gemini 44 | if shouldRedirect(r) { 45 | outbox := fmt.Sprintf("gemini://%s/outbox/%s", l.Domain, strings.TrimPrefix(actorID, "https://")) 46 | slog.Info("Redirecting to outbox over Gemini", "outbox", outbox) 47 | w.Header().Set("Location", outbox) 48 | w.WriteHeader(http.StatusMovedPermanently) 49 | return 50 | } 51 | 52 | w.Header().Set("Content-Type", `application/activity+json; charset=utf-8`) 53 | w.Write(danger.Bytes(actorString)) 54 | } 55 | 56 | func (l *Listener) handleUser(w http.ResponseWriter, r *http.Request) { 57 | l.doHandleUser(w, r, r.PathValue("username")) 58 | } 59 | 60 | func (l *Listener) handleActor(w http.ResponseWriter, r *http.Request) { 61 | l.doHandleUser(w, r, "actor") 62 | } 63 | -------------------------------------------------------------------------------- /fed/activity.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package fed 18 | 19 | import ( 20 | "database/sql" 21 | "encoding/json" 22 | "errors" 23 | "fmt" 24 | "log/slog" 25 | "net/http" 26 | 27 | "github.com/dimkr/tootik/ap" 28 | "github.com/dimkr/tootik/danger" 29 | ) 30 | 31 | func (l *Listener) handleActivity(w http.ResponseWriter, r *http.Request, prefix string) { 32 | activityID := fmt.Sprintf("https://%s/%s/%s", l.Domain, prefix, r.PathValue("hash")) 33 | 34 | slog.Info("Fetching activity", "activity", activityID) 35 | 36 | var raw string 37 | var activity ap.Activity 38 | if err := l.DB.QueryRowContext(r.Context(), `select json(activity), json(activity) as raw from outbox where cid = ?`, activityID).Scan(&raw, &activity); errors.Is(err, sql.ErrNoRows) { 39 | w.WriteHeader(http.StatusNotFound) 40 | return 41 | } else if err != nil { 42 | slog.Warn("Failed to fetch activity", "activity", activityID, "error", err) 43 | w.WriteHeader(http.StatusInternalServerError) 44 | return 45 | } 46 | 47 | if !activity.IsPublic() { 48 | slog.Warn("Refused attempt to fetch a non-public activity", "activity", activityID) 49 | w.WriteHeader(http.StatusNotFound) 50 | return 51 | } 52 | 53 | w.Header().Set("Content-Type", "application/activity+json; charset=utf-8") 54 | 55 | if activity.Type == ap.Update { 56 | json.NewEncoder(w).Encode(activity.Object) 57 | } else { 58 | w.Write(danger.Bytes(raw)) 59 | } 60 | } 61 | 62 | func (l *Listener) handleCreate(w http.ResponseWriter, r *http.Request) { 63 | l.handleActivity(w, r, "create") 64 | } 65 | 66 | func (l *Listener) handleUpdate(w http.ResponseWriter, r *http.Request) { 67 | l.handleActivity(w, r, "update") 68 | } 69 | -------------------------------------------------------------------------------- /httpsig/sign.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package httpsig 18 | 19 | import ( 20 | "crypto" 21 | "crypto/rsa" 22 | "crypto/sha256" 23 | "encoding/base64" 24 | "errors" 25 | "fmt" 26 | "net/http" 27 | "strings" 28 | "time" 29 | 30 | "github.com/dimkr/tootik/danger" 31 | ) 32 | 33 | var ( 34 | defaultHeaders = []string{"(request-target)", "host", "date"} 35 | postHeaders = []string{"(request-target)", "host", "date", "content-type", "digest"} 36 | ) 37 | 38 | // Sign adds a signature to an outgoing HTTP request. 39 | func Sign(r *http.Request, body []byte, key Key, now time.Time) error { 40 | if key.ID == "" { 41 | return errors.New("empty key ID") 42 | } 43 | 44 | headers := defaultHeaders 45 | if r.Method == http.MethodPost { 46 | hash := sha256.Sum256(body) 47 | r.Header.Set("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(hash[:])) 48 | 49 | headers = postHeaders 50 | } 51 | 52 | r.Header.Set("Date", now.UTC().Format(http.TimeFormat)) 53 | r.Header.Set("Host", r.URL.Host) 54 | 55 | s, err := buildSignatureString(r, headers) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | rsaKey, ok := key.PrivateKey.(*rsa.PrivateKey) 61 | if !ok { 62 | return errors.New("invalid private key") 63 | } 64 | 65 | hash := sha256.Sum256(danger.Bytes(s)) 66 | sig, err := rsa.SignPKCS1v15(nil, rsaKey, crypto.SHA256, hash[:]) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | r.Header.Set( 72 | "Signature", 73 | fmt.Sprintf( 74 | `keyId="%s",algorithm="rsa-sha256",headers="%s",signature="%s"`, 75 | key.ID, 76 | strings.Join(headers, " "), 77 | base64.StdEncoding.EncodeToString(sig), 78 | ), 79 | ) 80 | 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /front/logo.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | const ( 20 | logoAlt = "tootik" 21 | logo = ` 22 | .. . .. .. 23 | ... . .... ... 24 | ... . . . . ... .. . . . . ... ... 25 | .. . . .. . . .. . .. .. . . 26 | ., . . . . .. . .. . . 27 | . . . .. .. .... . . . . .. .. 28 | . .. ... . . . . . .. . . 29 | . . . .. . .. ...' .. . . . . . 30 | . . . . . __ . .__ _ __ ,; .'. . . .... 31 | . . . / /____ ___ /./_(_) /__ .' .. . . . . 32 | .. ... . . . /.__/ _ \/ _ \/ __/./ '_/. . .. . . . 33 | .' ... . \__/\___/\___/\__/_/_/\_\ . . . . 34 | . . . . . . ... ... . . .. 35 | .. .. . . .... .. . . .. . . . . . ... ....' . 36 | ... . . . .. . ... ... . .. .. .,.. ..... 37 | . .. ...... . .''. . .. . . . ... 38 | ' . .. .. .. . . ... ......::. .. .,. . .. .... .. 39 | . .... . ..... . .. . . ... . .,'. . .. ,.. .. 40 | . . . . . .. . . .. . . .. .. . . . . . . .' 41 | . .... '... ... . . .. . ... . '. ' ... 42 | 43 | ` 44 | ) 45 | -------------------------------------------------------------------------------- /cluster/cluster.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package cluster contains complex tests that involve multiple servers. 18 | package cluster 19 | 20 | import ( 21 | "testing" 22 | 23 | "github.com/dimkr/tootik/inbox" 24 | ) 25 | 26 | // Cluster represents a collection of servers that talk to each other. 27 | type Cluster Client 28 | 29 | // NewCluster creates a collection of servers that talk to each other. 30 | func NewCluster(t *testing.T, domain ...string) Cluster { 31 | t.Parallel() 32 | 33 | c := Client{} 34 | 35 | for _, d := range domain { 36 | c[d] = NewServer(t, d, c) 37 | } 38 | 39 | return Cluster(c) 40 | } 41 | 42 | // Settle waits until all servers are done processing queued activities, both incoming and outgoing. 43 | func (c Cluster) Settle(t *testing.T) { 44 | for { 45 | again := false 46 | 47 | for d, server := range c { 48 | if n, err := server.Incoming.ProcessBatch(t.Context()); err != nil { 49 | server.Test.Fatalf("Failed to process incoming queue on %s: %v", d, err) 50 | } else if n > 0 { 51 | again = true 52 | } 53 | 54 | if n, err := server.Outgoing.ProcessBatch(t.Context()); err != nil { 55 | server.Test.Fatalf("Failed to process outgoing queue on %s: %v", d, err) 56 | } else if n > 0 { 57 | again = true 58 | } 59 | } 60 | 61 | if !again { 62 | break 63 | } 64 | } 65 | 66 | for d, server := range c { 67 | if err := (inbox.FeedUpdater{Domain: d, Config: server.Config, DB: server.DB}).Run(t.Context()); err != nil { 68 | server.Test.Fatalf("Failed to update feeds on %s: %v", d, err) 69 | } 70 | } 71 | } 72 | 73 | // Stop stops all servers in the cluster. 74 | func (c Cluster) Stop() { 75 | for _, s := range c { 76 | s.Stop() 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /cluster/share_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package cluster 18 | 19 | import "testing" 20 | 21 | func TestCluster_ShareUnshare(t *testing.T) { 22 | cluster := NewCluster(t, "a.localdomain", "b.localdomain", "c.localdomain") 23 | defer cluster.Stop() 24 | 25 | alice := cluster["a.localdomain"].Register(aliceKeypair).OK() 26 | bob := cluster["b.localdomain"].Register(bobKeypair).OK() 27 | carol := cluster["c.localdomain"].Register(carolKeypair).OK() 28 | 29 | alice = alice. 30 | FollowInput("🔭 View profile", "bob@b.localdomain"). 31 | Follow("⚡ Follow bob"). 32 | OK() 33 | carol. 34 | FollowInput("🔭 View profile", "bob@b.localdomain"). 35 | Follow("⚡ Follow bob"). 36 | OK() 37 | carol = carol. 38 | FollowInput("🔭 View profile", "alice@a.localdomain"). 39 | Follow("⚡ Follow alice"). 40 | OK() 41 | cluster.Settle(t) 42 | 43 | post := bob. 44 | Follow("📣 New post"). 45 | FollowInput("📣 Anyone", "hello"). 46 | OK() 47 | cluster.Settle(t) 48 | 49 | share := alice.Goto(post.Path). 50 | Follow("🔁 Share"). 51 | OK() 52 | cluster.Settle(t) 53 | 54 | bob = bob. 55 | FollowInput("🔭 View profile", "alice@a.localdomain"). 56 | Contains(Line{Type: Quote, Text: "hello"}) 57 | alice. 58 | Refresh(). 59 | Contains(Line{Type: Quote, Text: "hello"}) 60 | carol. 61 | Refresh(). 62 | Contains(Line{Type: Quote, Text: "hello"}) 63 | 64 | share.Follow("🔄️ Unshare").OK() 65 | cluster.Settle(t) 66 | 67 | bob. 68 | FollowInput("🔭 View profile", "alice@a.localdomain"). 69 | NotContains(Line{Type: Quote, Text: "hello"}) 70 | alice. 71 | Follow("😈 My profile"). 72 | NotContains(Line{Type: Quote, Text: "hello"}) 73 | carol. 74 | Refresh(). 75 | NotContains(Line{Type: Quote, Text: "hello"}) 76 | } 77 | -------------------------------------------------------------------------------- /front/export.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "bufio" 21 | "encoding/csv" 22 | 23 | "github.com/dimkr/tootik/ap" 24 | "github.com/dimkr/tootik/front/text" 25 | ) 26 | 27 | const ( 28 | csvBufferSize = 32 * 1024 29 | csvRows = 200 30 | ) 31 | 32 | var csvHeader = []string{"ID", "Type", "Inserted", "Activity"} 33 | 34 | func (h *Handler) export(w text.Writer, r *Request, args ...string) { 35 | if r.User == nil { 36 | w.Redirect("/users") 37 | return 38 | } 39 | 40 | output := csv.NewWriter(bufio.NewWriterSize(w, csvBufferSize)) 41 | 42 | rows, err := h.DB.QueryContext( 43 | r.Context, 44 | ` 45 | select cid, activity->>'$.type', datetime(inserted, 'unixepoch'), json(activity) from outbox 46 | where 47 | activity->>'$.actor' in (select id from persons where cid = ?) 48 | order by inserted desc 49 | limit ? 50 | `, 51 | ap.Canonical(r.User.ID), 52 | csvRows, 53 | ) 54 | if err != nil { 55 | r.Log.Warn("Failed to fetch activities", "error", err) 56 | w.Error() 57 | return 58 | } 59 | defer rows.Close() 60 | 61 | w.Status(20, "text/csv") 62 | 63 | if err := output.Write(csvHeader); err != nil { 64 | r.Log.Warn("Failed to write header", "error", err) 65 | return 66 | } 67 | 68 | var fields [4]string 69 | for rows.Next() { 70 | if err := rows.Scan(&fields[0], &fields[1], &fields[2], &fields[3]); err != nil { 71 | r.Log.Warn("Failed to scan activity", "error", err) 72 | continue 73 | } 74 | if err := output.Write(fields[:]); err != nil { 75 | r.Log.Warn("Failed to write a line", "error", err) 76 | return 77 | } 78 | } 79 | 80 | output.Flush() 81 | if err := output.Error(); err != nil { 82 | r.Log.Warn("Failed to flush output", "error", err) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /front/bookmarks.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "database/sql" 21 | 22 | "github.com/dimkr/tootik/front/text" 23 | ) 24 | 25 | func (h *Handler) bookmarks(w text.Writer, r *Request, args ...string) { 26 | if r.User == nil { 27 | w.Redirect("/oops") 28 | return 29 | } 30 | 31 | h.showFeedPage( 32 | w, 33 | r, 34 | "🔖 Bookmarks", 35 | func(offset int) (*sql.Rows, error) { 36 | return h.DB.QueryContext( 37 | r.Context, 38 | `select json(notes.object), json(persons.actor), null as sharer, notes.inserted from bookmarks 39 | join notes 40 | on 41 | notes.id = bookmarks.note 42 | join persons 43 | on 44 | persons.id = notes.author 45 | where 46 | bookmarks.by = $1 and 47 | ( 48 | notes.author = $1 or 49 | notes.public = 1 or 50 | exists (select 1 from json_each(notes.object->'$.to') where exists (select 1 from follows join persons on persons.id = follows.followed where follows.follower = $1 and follows.followed = notes.author and follows.accepted = 1 and (notes.author = value or persons.actor->>'$.followers' = value))) or 51 | exists (select 1 from json_each(notes.object->'$.cc') where exists (select 1 from follows join persons on persons.id = follows.followed where follows.follower = $1 and follows.followed = notes.author and follows.accepted = 1 and (notes.author = value or persons.actor->>'$.followers' = value))) or 52 | exists (select 1 from json_each(notes.object->'$.to') where value = $1) or 53 | exists (select 1 from json_each(notes.object->'$.cc') where value = $1) 54 | ) 55 | order by bookmarks.inserted desc 56 | limit $2 57 | offset $3`, 58 | r.User.ID, 59 | h.Config.PostsPerPage, 60 | offset, 61 | ) 62 | }, 63 | false, 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /ap/audience.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | import ( 20 | "database/sql/driver" 21 | "encoding/json" 22 | "fmt" 23 | 24 | "github.com/dimkr/tootik/danger" 25 | "github.com/dimkr/tootik/data" 26 | ) 27 | 28 | // Audience is an ordered, unique list of actor IDs. 29 | type Audience struct { 30 | data.OrderedMap[string, struct{}] 31 | } 32 | 33 | func (a Audience) IsZero() bool { 34 | return len(a.OrderedMap) == 0 35 | } 36 | 37 | func (a *Audience) Add(s string) { 38 | if a.OrderedMap == nil { 39 | a.OrderedMap = make(data.OrderedMap[string, struct{}], 1) 40 | } 41 | 42 | a.OrderedMap.Store(s, struct{}{}) 43 | } 44 | 45 | func (a *Audience) UnmarshalJSON(b []byte) error { 46 | var l []string 47 | if err := json.Unmarshal(b, &l); err != nil { 48 | // Mastodon represents poll votes as a Create with a string in "to" 49 | var s string 50 | if err := json.Unmarshal(b, &s); err != nil { 51 | return err 52 | } 53 | 54 | a.OrderedMap = make(data.OrderedMap[string, struct{}], 1) 55 | a.Add(s) 56 | 57 | return nil 58 | } 59 | 60 | if len(l) == 0 { 61 | return nil 62 | } 63 | 64 | a.OrderedMap = make(data.OrderedMap[string, struct{}], len(l)) 65 | for _, s := range l { 66 | a.Add(s) 67 | } 68 | 69 | return nil 70 | } 71 | 72 | func (a Audience) MarshalJSON() ([]byte, error) { 73 | if len(a.OrderedMap) == 0 { 74 | return []byte("[]"), nil 75 | } 76 | 77 | return json.Marshal(a.CollectKeys()) 78 | } 79 | 80 | func (a *Audience) Scan(src any) error { 81 | if src == nil { 82 | return nil 83 | } 84 | 85 | s, ok := src.(string) 86 | if !ok { 87 | return fmt.Errorf("unsupported conversion from %T to %T", src, a) 88 | } 89 | return json.Unmarshal(danger.Bytes(s), a) 90 | } 91 | 92 | func (a *Audience) Value() (driver.Value, error) { 93 | return danger.MarshalJSON(a) 94 | } 95 | -------------------------------------------------------------------------------- /fed/icon.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package fed 18 | 19 | import ( 20 | "database/sql" 21 | "errors" 22 | "fmt" 23 | "log/slog" 24 | "net/http" 25 | "strings" 26 | 27 | "github.com/dimkr/tootik/icon" 28 | ) 29 | 30 | func (l *Listener) handleIcon(w http.ResponseWriter, r *http.Request) { 31 | if name, ok := strings.CutSuffix(r.PathValue("username"), icon.FileNameExtension); ok { 32 | l.doHandleIcon(w, r, fmt.Sprintf("https://%s/user/%s", l.Domain, name)) 33 | } else { 34 | w.WriteHeader(http.StatusNotFound) 35 | } 36 | } 37 | 38 | func (l *Listener) doHandleIcon(w http.ResponseWriter, r *http.Request, cid string) { 39 | slog.Info("Looking up cached icon", "cid", cid) 40 | 41 | var cache []byte 42 | if err := l.DB.QueryRowContext(r.Context(), `select buf from icons where cid = ?`, cid).Scan(&cache); err != nil && !errors.Is(err, sql.ErrNoRows) { 43 | slog.Warn("Failed to get cached icon", "cid", cid, "error", err) 44 | w.WriteHeader(http.StatusInternalServerError) 45 | return 46 | } else if err == nil && len(cache) > 0 { 47 | slog.Debug("Sending cached icon", "cid", cid) 48 | w.Header().Set("Content-Type", icon.MediaType) 49 | w.Write(cache) 50 | return 51 | } 52 | 53 | slog.Info("Generating an icon", "cid", cid) 54 | 55 | buf, err := icon.Generate(cid) 56 | if err != nil { 57 | slog.Warn("Failed to generate icon", "cid", cid, "error", err) 58 | w.WriteHeader(http.StatusInternalServerError) 59 | return 60 | } 61 | 62 | if _, err := l.DB.ExecContext(r.Context(), `insert into icons(cid, buf) values($1, $2) on conflict(cid) do update set buf = $2`, cid, buf); err != nil { 63 | slog.Warn("Failed to cache icon", "cid", cid, "error", err) 64 | w.WriteHeader(http.StatusInternalServerError) 65 | return 66 | } 67 | 68 | w.Header().Set("Content-Type", icon.MediaType) 69 | w.Write(buf) 70 | } 71 | -------------------------------------------------------------------------------- /fed/blocklist_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package fed 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/stretchr/testify/assert" 23 | ) 24 | 25 | func TestBlockList_NotBlockedDomain(t *testing.T) { 26 | assert := assert.New(t) 27 | 28 | blockList := BlockList{} 29 | blockList.domains = map[string]struct{}{ 30 | "0.0.0.0.com": {}, 31 | } 32 | 33 | assert.False(blockList.Contains("127.0.0.1.com")) 34 | } 35 | 36 | func TestBlockList_BlockedDomain(t *testing.T) { 37 | assert := assert.New(t) 38 | 39 | blockList := BlockList{} 40 | blockList.domains = map[string]struct{}{ 41 | "0.0.0.0.com": {}, 42 | } 43 | 44 | assert.True(blockList.Contains("0.0.0.0.com")) 45 | } 46 | 47 | func TestBlockList_BlockedSubdomain(t *testing.T) { 48 | assert := assert.New(t) 49 | 50 | blockList := BlockList{} 51 | blockList.domains = map[string]struct{}{ 52 | "social.0.0.0.0.com": {}, 53 | } 54 | 55 | assert.True(blockList.Contains("social.0.0.0.0.com")) 56 | } 57 | 58 | func TestBlockList_NotBlockedSubdomain(t *testing.T) { 59 | assert := assert.New(t) 60 | 61 | blockList := BlockList{} 62 | blockList.domains = map[string]struct{}{ 63 | "social.0.0.0.0.com": {}, 64 | } 65 | 66 | assert.False(blockList.Contains("blog.0.0.0.0.com")) 67 | } 68 | 69 | func TestBlockList_BlockedSubdomainByDomain(t *testing.T) { 70 | assert := assert.New(t) 71 | 72 | blockList := BlockList{} 73 | blockList.domains = map[string]struct{}{ 74 | "0.0.0.0.com": {}, 75 | } 76 | 77 | assert.True(blockList.Contains("social.0.0.0.0.com")) 78 | } 79 | 80 | func TestBlockList_BlockedSubdomainByDomainEndsWithDot(t *testing.T) { 81 | assert := assert.New(t) 82 | 83 | blockList := BlockList{} 84 | blockList.domains = map[string]struct{}{ 85 | "0.0.0.0.com": {}, 86 | } 87 | 88 | assert.True(blockList.Contains("social.0.0.0.0.com.")) 89 | } 90 | -------------------------------------------------------------------------------- /test/say_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package test 18 | 19 | import ( 20 | "strings" 21 | "testing" 22 | 23 | "github.com/stretchr/testify/assert" 24 | ) 25 | 26 | func TestSay_HappyFlow(t *testing.T) { 27 | server := newTestServer() 28 | defer server.Shutdown() 29 | 30 | assert := assert.New(t) 31 | 32 | say := server.Handle("/users/say?Hello%20world", server.Alice) 33 | assert.Regexp(`^30 /users/view/\S+\r\n$`, say) 34 | 35 | view := server.Handle(say[3:len(say)-2], server.Bob) 36 | assert.Contains(view, "Hello world") 37 | 38 | outbox := server.Handle("/users/outbox/"+strings.TrimPrefix(server.Alice.ID, "https://"), server.Bob) 39 | assert.Contains(outbox, "Hello world") 40 | 41 | local := server.Handle("/local", server.Carol) 42 | assert.Contains(local, "Hello world") 43 | } 44 | 45 | func TestSay_Throttling(t *testing.T) { 46 | server := newTestServer() 47 | defer server.Shutdown() 48 | 49 | assert := assert.New(t) 50 | 51 | say := server.Handle("/users/say?Hello%20world", server.Alice) 52 | assert.Regexp(`^30 /users/view/\S+\r\n$`, say) 53 | 54 | view := server.Handle(say[3:len(say)-2], server.Bob) 55 | assert.Contains(view, "Hello world") 56 | 57 | outbox := server.Handle("/users/outbox/"+strings.TrimPrefix(server.Alice.ID, "https://"), server.Alice) 58 | assert.Contains(outbox, "Hello world") 59 | 60 | say = server.Handle("/users/say?Hello%20once%20more,%20world", server.Alice) 61 | assert.Regexp(`^40 Please wait for \S+\r\n$`, say) 62 | 63 | outbox = server.Handle("/users/outbox/"+strings.TrimPrefix(server.Alice.ID, "https://"), server.Bob) 64 | assert.Contains(outbox, "Hello world") 65 | assert.NotContains(outbox, "Hello once more, world") 66 | 67 | local := server.Handle("/local", server.Carol) 68 | assert.Contains(local, "Hello world") 69 | assert.NotContains(local, "Hello once more, world") 70 | } 71 | -------------------------------------------------------------------------------- /ap/audience_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ap 18 | 19 | import ( 20 | "encoding/json" 21 | "testing" 22 | 23 | "github.com/dimkr/tootik/data" 24 | ) 25 | 26 | func TestAudienceMarshal_Happyflow(t *testing.T) { 27 | to := Audience{} 28 | to.Add("x") 29 | to.Add("y") 30 | to.Add("y") 31 | 32 | if j, err := json.Marshal(struct { 33 | ID string `json:"id"` 34 | To Audience `json:"to,omitzero"` 35 | }{ 36 | ID: "a", 37 | To: to, 38 | }); err != nil { 39 | t.Fatalf("Failed to marshal: %v", err) 40 | } else if string(j) != `{"id":"a","to":["x","y"]}` { 41 | t.Fatalf("Unexpected result: %s", string(j)) 42 | } 43 | } 44 | 45 | func TestAudienceMarshal_NilOmitZero(t *testing.T) { 46 | if j, err := json.Marshal(struct { 47 | ID string `json:"id"` 48 | To Audience `json:"to,omitzero"` 49 | }{ 50 | ID: "a", 51 | }); err != nil { 52 | t.Fatalf("Failed to marshal: %v", err) 53 | } else if string(j) != `{"id":"a"}` { 54 | t.Fatalf("Unexpected result: %s", string(j)) 55 | } 56 | } 57 | 58 | func TestAudienceMarshal_NilMapOmitZero(t *testing.T) { 59 | if j, err := json.Marshal(struct { 60 | ID string `json:"id"` 61 | To Audience `json:"to,omitzero"` 62 | }{ 63 | ID: "a", 64 | To: Audience{}, 65 | }); err != nil { 66 | t.Fatalf("Failed to marshal: %v", err) 67 | } else if string(j) != `{"id":"a"}` { 68 | t.Fatalf("Unexpected result: %s", string(j)) 69 | } 70 | } 71 | 72 | func TestAudienceMarshal_EmptyOmitZero(t *testing.T) { 73 | if j, err := json.Marshal(struct { 74 | ID string `json:"id"` 75 | To Audience `json:"tag,omitzero"` 76 | }{ 77 | ID: "a", 78 | To: Audience{ 79 | OrderedMap: data.OrderedMap[string, struct{}]{}, 80 | }, 81 | }); err != nil { 82 | t.Fatalf("Failed to marshal: %v", err) 83 | } else if string(j) != `{"id":"a"}` { 84 | t.Fatalf("Unexpected result: %s", string(j)) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /front/alias.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "net/url" 21 | "strings" 22 | "time" 23 | 24 | "github.com/dimkr/tootik/ap" 25 | "github.com/dimkr/tootik/front/text" 26 | ) 27 | 28 | func (h *Handler) alias(w text.Writer, r *Request, args ...string) { 29 | if r.User == nil { 30 | w.Redirect("/users") 31 | return 32 | } 33 | 34 | now := time.Now() 35 | 36 | can := r.User.Published.Time.Add(h.Config.MinActorEditInterval) 37 | if r.User.Updated != (ap.Time{}) { 38 | can = r.User.Updated.Time.Add(h.Config.MinActorEditInterval) 39 | } 40 | if now.Before(can) { 41 | r.Log.Warn("Throttled request to set alias", "can", can) 42 | w.Statusf(40, "Please wait for %s", time.Until(can).Truncate(time.Second).String()) 43 | return 44 | } 45 | 46 | if r.URL.RawQuery == "" { 47 | w.Status(10, "Alias (name@domain)") 48 | return 49 | } 50 | 51 | alias, err := url.QueryUnescape(r.URL.RawQuery) 52 | if err != nil { 53 | r.Log.Warn("Failed to decode alias", "query", r.URL.RawQuery, "error", err) 54 | w.Status(40, "Bad input") 55 | return 56 | } 57 | 58 | tokens := strings.SplitN(alias, "@", 3) 59 | if len(tokens) != 2 { 60 | r.Log.Warn("Alias is invalid", "alias", alias) 61 | w.Status(40, "Bad input") 62 | return 63 | } 64 | 65 | actor, err := h.Resolver.Resolve(r.Context, r.Keys, tokens[1], tokens[0], 0) 66 | if err != nil { 67 | r.Log.Warn("Failed to resolve alias", "alias", alias, "error", err) 68 | w.Status(40, "Failed to resolve "+alias) 69 | return 70 | } 71 | 72 | r.User.AlsoKnownAs.Add(actor.ID) 73 | r.User.Updated.Time = now 74 | 75 | if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { 76 | r.Log.Error("Failed to update alias", "error", err) 77 | w.Error() 78 | return 79 | } 80 | 81 | w.Redirect("/users/outbox/" + strings.TrimPrefix(actor.ID, "https://")) 82 | } 83 | -------------------------------------------------------------------------------- /front/communities.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package front 18 | 19 | import ( 20 | "strings" 21 | "time" 22 | 23 | "github.com/dimkr/tootik/front/text" 24 | ) 25 | 26 | func (h *Handler) communities(w text.Writer, r *Request, args ...string) { 27 | rows, err := h.DB.QueryContext( 28 | r.Context, 29 | ` 30 | select u.id, u.username, max(u.inserted) from ( 31 | select persons.id, persons.actor->>'preferredUsername' as username, shares.inserted from shares 32 | join persons 33 | on 34 | persons.id = shares.by 35 | where 36 | persons.host = $1 and 37 | persons.actor->>'$.type' = 'Group' 38 | union all 39 | select persons.id, persons.actor->>'preferredUsername' as username, notes.inserted from notes 40 | join persons 41 | on 42 | persons.id = notes.author 43 | where 44 | persons.host = $1 and 45 | persons.actor->>'$.type' = 'Group' 46 | ) u 47 | group by 48 | u.id 49 | order by 50 | max(u.inserted) desc 51 | `, 52 | h.Domain, 53 | ) 54 | if err != nil { 55 | r.Log.Error("Failed to list communities", "error", err) 56 | w.Error() 57 | return 58 | } 59 | 60 | w.OK() 61 | 62 | w.Title("🏕️ Communities") 63 | 64 | empty := true 65 | 66 | for rows.Next() { 67 | var id, username string 68 | var last int64 69 | if err := rows.Scan(&id, &username, &last); err != nil { 70 | r.Log.Warn("Failed to scan community", "error", err) 71 | continue 72 | } 73 | 74 | if r.User == nil { 75 | w.Linkf("/outbox/"+strings.TrimPrefix(id, "https://"), "%s %s", time.Unix(last, 0).Format(time.DateOnly), username) 76 | } else { 77 | w.Linkf("/users/outbox/"+strings.TrimPrefix(id, "https://"), "%s %s", time.Unix(last, 0).Format(time.DateOnly), username) 78 | } 79 | 80 | empty = false 81 | } 82 | 83 | rows.Close() 84 | 85 | if empty { 86 | w.Text("No communities.") 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /inbox/move.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package inbox 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "time" 23 | 24 | "github.com/dimkr/tootik/ap" 25 | "github.com/dimkr/tootik/httpsig" 26 | "github.com/dimkr/tootik/proof" 27 | ) 28 | 29 | func (inbox *Inbox) move(ctx context.Context, from *ap.Actor, key httpsig.Key, to string) error { 30 | aud := ap.Audience{} 31 | aud.Add(from.Followers) 32 | 33 | id, err := inbox.NewID(from.ID, "move") 34 | if err != nil { 35 | return err 36 | } 37 | 38 | move := &ap.Activity{ 39 | Context: []string{ 40 | "https://www.w3.org/ns/activitystreams", 41 | "https://w3id.org/security/data-integrity/v1", 42 | "https://w3id.org/security/v1", 43 | }, 44 | ID: id, 45 | Actor: from.ID, 46 | Type: ap.Move, 47 | Object: from.ID, 48 | Target: to, 49 | To: aud, 50 | } 51 | 52 | if !inbox.Config.DisableIntegrityProofs { 53 | if move.Proof, err = proof.Create(key, move); err != nil { 54 | return err 55 | } 56 | } 57 | 58 | tx, err := inbox.DB.BeginTx(ctx, nil) 59 | if err != nil { 60 | return fmt.Errorf("failed to begin transaction: %w", err) 61 | } 62 | defer tx.Rollback() 63 | 64 | from.MovedTo = to 65 | from.Updated.Time = time.Now() 66 | if err := inbox.UpdateActorTx(ctx, tx, from, key); err != nil { 67 | return err 68 | } 69 | 70 | if _, err := tx.ExecContext( 71 | ctx, 72 | `insert into outbox (activity, sender) values (jsonb(?), ?)`, 73 | move, 74 | from.ID, 75 | ); err != nil { 76 | return err 77 | } 78 | 79 | return tx.Commit() 80 | } 81 | 82 | // Move queues a Move activity for delivery. 83 | func (inbox *Inbox) Move(ctx context.Context, from *ap.Actor, key httpsig.Key, to string) error { 84 | if err := inbox.move(ctx, from, key, to); err != nil { 85 | return fmt.Errorf("failed to move %s to %s: %w", from.ID, to, err) 86 | } 87 | 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /migrations/004_outbox.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | ) 8 | 9 | func outbox(ctx context.Context, domain string, tx *sql.Tx) error { 10 | if _, err := tx.ExecContext(ctx, `CREATE TABLE outbox(activity STRING NOT NULL, inserted INTEGER DEFAULT (UNIXEPOCH()), attempts INTEGER DEFAULT 0, last INTEGER DEFAULT (UNIXEPOCH()), sent INTEGER DEFAULT 0)`); err != nil { 11 | return err 12 | } 13 | 14 | if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX outboxactivityid ON outbox(activity->>'id')`); err != nil { 15 | return err 16 | } 17 | 18 | if _, err := tx.ExecContext(ctx, `CREATE INDEX outboxsentattempts ON outbox(sent, attempts)`); err != nil { 19 | return err 20 | } 21 | 22 | if _, err := tx.ExecContext(ctx, `ALTER TABLE follows ADD accepted INTEGER DEFAULT 0`); err != nil { 23 | return err 24 | } 25 | 26 | if _, err := tx.ExecContext(ctx, `UPDATE follows SET accepted = 1`); err != nil { 27 | return err 28 | } 29 | 30 | if _, err := tx.ExecContext(ctx, `ALTER TABLE activities RENAME TO inbox`); err != nil { 31 | return err 32 | } 33 | 34 | if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX inboxid ON inbox(activity->>'id')`); err != nil { 35 | return err 36 | } 37 | 38 | if _, err := tx.ExecContext(ctx, `DROP INDEX activitiesid`); err != nil { 39 | return err 40 | } 41 | 42 | if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ADD certhash STRING`); err != nil { 43 | return err 44 | } 45 | 46 | if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX personscerthash ON persons(certhash)`); err != nil { 47 | return err 48 | } 49 | 50 | if _, err := tx.ExecContext(ctx, `UPDATE persons SET certhash = actor->>'clientCertificate'`); err != nil { 51 | return err 52 | } 53 | 54 | if _, err := tx.ExecContext(ctx, `UPDATE persons SET actor = json_remove(actor, '$.clientCertificate')`); err != nil { 55 | return err 56 | } 57 | 58 | if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ADD privkey STRING`); err != nil { 59 | return err 60 | } 61 | 62 | if _, err := tx.ExecContext(ctx, `UPDATE persons SET privkey = actor->>'privateKey'`); err != nil { 63 | return err 64 | } 65 | 66 | if _, err := tx.ExecContext(ctx, `UPDATE persons SET actor = json_remove(actor, '$.privateKey')`); err != nil { 67 | return err 68 | } 69 | 70 | if _, err := tx.ExecContext(ctx, `UPDATE notes SET object = json_remove(object, '$.url') where id like ?`, fmt.Sprintf("https://%s/%%", domain)); err != nil { 71 | return err 72 | } 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /inbox/undo.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package inbox 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | "github.com/dimkr/tootik/ap" 24 | "github.com/dimkr/tootik/danger" 25 | "github.com/dimkr/tootik/httpsig" 26 | "github.com/dimkr/tootik/proof" 27 | ) 28 | 29 | func (inbox *Inbox) undo(ctx context.Context, actor *ap.Actor, key httpsig.Key, activity *ap.Activity) error { 30 | id, err := inbox.NewID(actor.ID, "undo") 31 | if err != nil { 32 | return err 33 | } 34 | 35 | to := activity.To 36 | to.Add(ap.Public) 37 | 38 | undo := &ap.Activity{ 39 | Context: []string{ 40 | "https://www.w3.org/ns/activitystreams", 41 | "https://w3id.org/security/data-integrity/v1", 42 | "https://w3id.org/security/v1", 43 | }, 44 | ID: id, 45 | Type: ap.Undo, 46 | Actor: actor.ID, 47 | To: to, 48 | CC: activity.CC, 49 | Object: activity, 50 | } 51 | 52 | if !inbox.Config.DisableIntegrityProofs { 53 | if undo.Proof, err = proof.Create(key, undo); err != nil { 54 | return err 55 | } 56 | } 57 | 58 | s, err := danger.MarshalJSON(undo) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | tx, err := inbox.DB.BeginTx(ctx, nil) 64 | if err != nil { 65 | return err 66 | } 67 | defer tx.Rollback() 68 | 69 | if _, err := tx.ExecContext( 70 | ctx, 71 | `INSERT INTO outbox (activity, sender) VALUES (JSONB(?), ?)`, 72 | s, 73 | activity.Actor, 74 | ); err != nil { 75 | return err 76 | } 77 | 78 | if err := inbox.ProcessActivity(ctx, tx, actor, undo, s, 1, false); err != nil { 79 | return err 80 | } 81 | 82 | return tx.Commit() 83 | } 84 | 85 | // Undo queues an Undo activity for delivery. 86 | func (inbox *Inbox) Undo(ctx context.Context, actor *ap.Actor, key httpsig.Key, activity *ap.Activity) error { 87 | if err := inbox.undo(ctx, actor, key, activity); err != nil { 88 | return fmt.Errorf("failed to undo %s by %s: %w", activity.ID, actor.ID, err) 89 | } 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /inbox/reject.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package inbox 18 | 19 | import ( 20 | "context" 21 | "database/sql" 22 | "fmt" 23 | 24 | "github.com/dimkr/tootik/ap" 25 | "github.com/dimkr/tootik/danger" 26 | "github.com/dimkr/tootik/httpsig" 27 | "github.com/dimkr/tootik/proof" 28 | ) 29 | 30 | func (inbox *Inbox) reject(ctx context.Context, followed *ap.Actor, key httpsig.Key, follower, followID string, tx *sql.Tx) error { 31 | id, err := inbox.NewID(followed.ID, "reject") 32 | if err != nil { 33 | return err 34 | } 35 | 36 | recipients := ap.Audience{} 37 | recipients.Add(follower) 38 | 39 | reject := &ap.Activity{ 40 | Context: []string{ 41 | "https://www.w3.org/ns/activitystreams", 42 | "https://w3id.org/security/data-integrity/v1", 43 | "https://w3id.org/security/v1", 44 | }, 45 | Type: ap.Reject, 46 | ID: id, 47 | Actor: followed.ID, 48 | To: recipients, 49 | Object: &ap.Activity{ 50 | Actor: follower, 51 | Type: ap.Follow, 52 | Object: followed, 53 | ID: followID, 54 | }, 55 | } 56 | 57 | if !inbox.Config.DisableIntegrityProofs { 58 | if reject.Proof, err = proof.Create(key, reject); err != nil { 59 | return err 60 | } 61 | } 62 | 63 | s, err := danger.MarshalJSON(reject) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if _, err := tx.ExecContext( 69 | ctx, 70 | `INSERT INTO outbox (activity, sender) VALUES (JSONB(?), ?)`, 71 | s, 72 | followed.ID, 73 | ); err != nil { 74 | return err 75 | } 76 | 77 | return inbox.ProcessActivity(ctx, tx, followed, reject, s, 1, false) 78 | } 79 | 80 | // Reject queues a Reject activity for delivery. 81 | func (inbox *Inbox) Reject(ctx context.Context, followed *ap.Actor, key httpsig.Key, follower, followID string, tx *sql.Tx) error { 82 | if err := inbox.reject(ctx, followed, key, follower, followID, tx); err != nil { 83 | return fmt.Errorf("failed to reject %s from %s by %s: %w", followID, follower, followed.ID, err) 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /inbox/accept.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 - 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package inbox 18 | 19 | import ( 20 | "context" 21 | "database/sql" 22 | "fmt" 23 | 24 | "github.com/dimkr/tootik/ap" 25 | "github.com/dimkr/tootik/danger" 26 | "github.com/dimkr/tootik/httpsig" 27 | "github.com/dimkr/tootik/proof" 28 | ) 29 | 30 | func (inbox *Inbox) accept(ctx context.Context, followed *ap.Actor, key httpsig.Key, follower, followID string, tx *sql.Tx) error { 31 | id, err := inbox.NewID(followed.ID, "accept") 32 | if err != nil { 33 | return err 34 | } 35 | 36 | recipients := ap.Audience{} 37 | recipients.Add(follower) 38 | 39 | accept := &ap.Activity{ 40 | Context: []string{ 41 | "https://www.w3.org/ns/activitystreams", 42 | "https://w3id.org/security/data-integrity/v1", 43 | "https://w3id.org/security/v1", 44 | }, 45 | Type: ap.Accept, 46 | ID: id, 47 | Actor: followed.ID, 48 | To: recipients, 49 | Object: &ap.Activity{ 50 | Actor: follower, 51 | Type: ap.Follow, 52 | Object: followed, 53 | ID: followID, 54 | }, 55 | } 56 | 57 | if !inbox.Config.DisableIntegrityProofs { 58 | if accept.Proof, err = proof.Create(key, accept); err != nil { 59 | return err 60 | } 61 | } 62 | 63 | s, err := danger.MarshalJSON(accept) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if _, err := tx.ExecContext( 69 | ctx, 70 | `INSERT INTO outbox (activity, sender) VALUES (JSONB(?), ?)`, 71 | s, 72 | followed.ID, 73 | ); err != nil { 74 | return err 75 | } 76 | 77 | return inbox.ProcessActivity(ctx, tx, followed, accept, s, 1, false) 78 | } 79 | 80 | // Accept queues an Accept activity for delivery. 81 | func (inbox *Inbox) Accept(ctx context.Context, followed *ap.Actor, key httpsig.Key, follower, followID string, tx *sql.Tx) error { 82 | if err := inbox.accept(ctx, followed, key, follower, followID, tx); err != nil { 83 | return fmt.Errorf("failed to accept %s from %s by %s: %w", followID, follower, followed.ID, err) 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /inbox/announce.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package inbox 18 | 19 | import ( 20 | "context" 21 | "database/sql" 22 | "fmt" 23 | "time" 24 | 25 | "github.com/dimkr/tootik/ap" 26 | "github.com/dimkr/tootik/danger" 27 | "github.com/dimkr/tootik/httpsig" 28 | "github.com/dimkr/tootik/proof" 29 | ) 30 | 31 | func (inbox *Inbox) announce(ctx context.Context, tx *sql.Tx, actor *ap.Actor, key httpsig.Key, note *ap.Object) error { 32 | announceID, err := inbox.NewID(actor.ID, "announce") 33 | if err != nil { 34 | return err 35 | } 36 | 37 | to := ap.Audience{} 38 | to.Add(ap.Public) 39 | 40 | cc := ap.Audience{} 41 | to.Add(note.AttributedTo) 42 | to.Add(actor.Followers) 43 | 44 | announce := &ap.Activity{ 45 | Context: []string{ 46 | "https://www.w3.org/ns/activitystreams", 47 | "https://w3id.org/security/data-integrity/v1", 48 | "https://w3id.org/security/v1", 49 | }, 50 | ID: announceID, 51 | Type: ap.Announce, 52 | Actor: actor.ID, 53 | Published: ap.Time{Time: time.Now()}, 54 | To: to, 55 | CC: cc, 56 | Object: note.ID, 57 | } 58 | 59 | if !inbox.Config.DisableIntegrityProofs { 60 | if announce.Proof, err = proof.Create(key, announce); err != nil { 61 | return err 62 | } 63 | } 64 | 65 | s, err := danger.MarshalJSON(announce) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | if _, err := tx.ExecContext( 71 | ctx, 72 | `INSERT INTO outbox (activity, sender) VALUES (JSONB(?), ?)`, 73 | s, 74 | actor.ID, 75 | ); err != nil { 76 | return err 77 | } 78 | 79 | return inbox.ProcessActivity(ctx, tx, actor, announce, s, 1, false) 80 | } 81 | 82 | // Announce queues an Announce activity for delivery. 83 | func (inbox *Inbox) Announce(ctx context.Context, tx *sql.Tx, actor *ap.Actor, key httpsig.Key, note *ap.Object) error { 84 | if err := inbox.announce(ctx, tx, actor, key, note); err != nil { 85 | return fmt.Errorf("failed to announce %s by %s: %w", note.ID, actor.ID, err) 86 | } 87 | 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /test/name_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024, 2025 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package test 18 | 19 | import ( 20 | "strings" 21 | "testing" 22 | "time" 23 | 24 | "github.com/stretchr/testify/assert" 25 | ) 26 | 27 | func TestName_Throttled(t *testing.T) { 28 | server := newTestServer() 29 | defer server.Shutdown() 30 | 31 | assert := assert.New(t) 32 | 33 | summary := server.Handle("/users/name/set?Jane%20Doe", server.Alice) 34 | assert.Regexp(`^40 Please wait for \S+\r\n$`, summary) 35 | } 36 | 37 | func TestName_HappyFlow(t *testing.T) { 38 | server := newTestServer() 39 | defer server.Shutdown() 40 | 41 | assert := assert.New(t) 42 | 43 | server.Alice.Published.Time = server.Alice.Published.Time.Add(-time.Hour) 44 | 45 | summary := server.Handle("/users/name/set?Jane%20Doe", server.Alice) 46 | assert.Equal("30 /users/name\r\n", summary) 47 | 48 | outbox := server.Handle("/users/outbox/"+strings.TrimPrefix(server.Alice.ID, "https://"), server.Bob) 49 | assert.Contains(strings.Split(outbox, "\n"), "# 😈 Jane Doe (alice@localhost.localdomain:8443)") 50 | } 51 | 52 | func TestName_TooLong(t *testing.T) { 53 | server := newTestServer() 54 | defer server.Shutdown() 55 | 56 | assert := assert.New(t) 57 | 58 | server.Alice.Published.Time = server.Alice.Published.Time.Add(-time.Hour) 59 | 60 | summary := server.Handle("/users/name/set?aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", server.Alice) 61 | assert.Equal("40 Display name is too long\r\n", summary) 62 | } 63 | 64 | func TestName_MultiLine(t *testing.T) { 65 | server := newTestServer() 66 | defer server.Shutdown() 67 | 68 | assert := assert.New(t) 69 | 70 | server.Alice.Published.Time = server.Alice.Published.Time.Add(-time.Hour) 71 | 72 | summary := server.Handle("/users/name/set?Jane%0A%0A%0A%0ADoe", server.Alice) 73 | assert.Equal("30 /users/name\r\n", summary) 74 | 75 | outbox := strings.Split(server.Handle("/users/outbox/"+strings.TrimPrefix(server.Alice.ID, "https://"), server.Bob), "\n") 76 | assert.Contains(outbox, "# 😈 Jane Doe (alice@localhost.localdomain:8443)") 77 | } 78 | -------------------------------------------------------------------------------- /front/static/embed.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package static serves static content. 18 | package static 19 | 20 | import ( 21 | "bytes" 22 | "embed" 23 | "fmt" 24 | "strings" 25 | "text/template" 26 | 27 | "github.com/dimkr/tootik/cfg" 28 | "github.com/dimkr/tootik/danger" 29 | ) 30 | 31 | type data struct { 32 | Domain string 33 | Config *cfg.Config 34 | } 35 | 36 | //go:embed *.gmi */*.gmi 37 | var vfs embed.FS 38 | 39 | var templates = map[string]*template.Template{} 40 | 41 | func Format(domain string, cfg *cfg.Config) (map[string][]string, error) { 42 | formatted := make(map[string][]string, len(templates)) 43 | 44 | data := data{ 45 | Domain: domain, 46 | Config: cfg, 47 | } 48 | 49 | for path, tmpl := range templates { 50 | var b bytes.Buffer 51 | if err := tmpl.Execute(&b, &data); err != nil { 52 | return nil, err 53 | } 54 | 55 | formatted[path] = strings.Split(strings.TrimRight(b.String(), "\r\n\t "), "\n") 56 | } 57 | 58 | return formatted, nil 59 | } 60 | 61 | func readDirectory(dir string) { 62 | files, err := vfs.ReadDir(dir) 63 | if err != nil { 64 | panic(err) 65 | } 66 | 67 | for _, file := range files { 68 | if file.IsDir() { 69 | readDirectory(file.Name()) 70 | continue 71 | } 72 | 73 | name := file.Name() 74 | 75 | path := name 76 | if dir != "." { 77 | path = fmt.Sprintf("%s/%s", dir, path) 78 | } 79 | 80 | content, err := vfs.ReadFile(path) 81 | if err != nil { 82 | panic(err) 83 | } 84 | 85 | base := name 86 | if dot := strings.LastIndexByte(name, '.'); dot > 0 { 87 | base = base[:dot] 88 | } 89 | 90 | if dir == "." { 91 | path = fmt.Sprintf("/%s", base) 92 | } else { 93 | path = fmt.Sprintf("/%s/%s", dir, base) 94 | } 95 | 96 | tmpl, err := template.New(path).Parse(danger.String(content)) 97 | if err != nil { 98 | panic(err) 99 | } 100 | 101 | templates[path] = tmpl 102 | } 103 | } 104 | 105 | func init() { 106 | readDirectory(".") 107 | } 108 | -------------------------------------------------------------------------------- /test/search_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Dima Krasner 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package test 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/stretchr/testify/assert" 23 | ) 24 | 25 | func TestSearch_Happyflow(t *testing.T) { 26 | server := newTestServer() 27 | defer server.Shutdown() 28 | 29 | assert := assert.New(t) 30 | 31 | search := server.Handle("/users/search?world", server.Bob) 32 | assert.Equal("30 /users/hashtag/world\r\n", search) 33 | } 34 | 35 | func TestSearch_LeadingHash(t *testing.T) { 36 | server := newTestServer() 37 | defer server.Shutdown() 38 | 39 | assert := assert.New(t) 40 | 41 | search := server.Handle("/users/search?%23world", server.Bob) 42 | assert.Equal("30 /users/hashtag/world\r\n", search) 43 | } 44 | 45 | func TestSearch_LeadingHashUnauthenticatedUser(t *testing.T) { 46 | server := newTestServer() 47 | defer server.Shutdown() 48 | 49 | assert := assert.New(t) 50 | 51 | search := server.Handle("/search?%23world", nil) 52 | assert.Equal("30 /hashtag/world\r\n", search) 53 | } 54 | 55 | func TestSearch_NoInput(t *testing.T) { 56 | server := newTestServer() 57 | defer server.Shutdown() 58 | 59 | assert := assert.New(t) 60 | 61 | search := server.Handle("/users/search?", server.Bob) 62 | assert.Equal("10 Hashtag\r\n", search) 63 | } 64 | 65 | func TestSearch_EmptyInput(t *testing.T) { 66 | server := newTestServer() 67 | defer server.Shutdown() 68 | 69 | assert := assert.New(t) 70 | 71 | search := server.Handle("/users/search?", server.Bob) 72 | assert.Equal("10 Hashtag\r\n", search) 73 | } 74 | 75 | func TestSearch_InvalidEscapeSequence(t *testing.T) { 76 | server := newTestServer() 77 | defer server.Shutdown() 78 | 79 | assert := assert.New(t) 80 | 81 | search := server.Handle("/users/search?%zzworld", server.Bob) 82 | assert.Equal("40 Bad input\r\n", search) 83 | } 84 | 85 | func TestSearch_UnathenticatedUser(t *testing.T) { 86 | server := newTestServer() 87 | defer server.Shutdown() 88 | 89 | assert := assert.New(t) 90 | 91 | search := server.Handle("/search?world", nil) 92 | assert.Equal("30 /hashtag/world\r\n", search) 93 | } 94 | --------------------------------------------------------------------------------