├── .github
├── dependabot.yml
└── workflows
│ ├── codeql.yml
│ └── go.yml
├── .gitignore
├── LICENSE
├── README.md
├── clients
└── hashlookup.go
├── collections
├── README.md
├── countItems.go
├── countItems_test.go
├── iteratorOption.go
├── range-documents.go
├── range-documentsBefore.go
├── range-documents_test.go
├── range-pages.go
└── range-pages_test.go
├── example
└── README.md
├── go.mod
├── go.sum
├── inbox
├── README.md
├── option.go
├── receive.go
├── receiveConfig.go
├── receive_test.go
├── router.go
├── utils.go
├── utils_test.go
└── validator.go
├── meta
├── logo.jpg
└── sigs.jpg
├── outbox
├── README.md
├── actor-.go
├── actor-send-accept.go
├── actor-send-announce.go
├── actor-send-create.go
├── actor-send-delete.go
├── actor-send-dislike.go
├── actor-send-follow.go
├── actor-send-like.go
├── actor-send-undo.go
├── actor-send-update.go
├── actor-send.go
├── actorOption.go
├── remoteOption.go
└── utils.go
├── property
├── README.md
├── bool.go
├── float.go
├── int.go
├── int64.go
├── interfaces.go
├── map.go
├── nil.go
├── slice.go
├── string.go
├── string_test.go
├── time.go
└── value.go
├── sigs
├── README.md
├── certificates.go
├── certificates_test.go
├── constants.go
├── digest.go
├── digestFunc.go
├── digest_test.go
├── httpsig.go
├── remoteMiddleware.go
├── request_test.go
├── signature.go
├── signature_test.go
├── signer.go
├── signerOption.go
├── signer_test.go
├── test_test.go
├── utils.go
├── utils_test.go
├── verifier.go
├── verifierOption.go
├── verifier_mock.go
├── verifier_test.go
├── x_dinochiesa_test.go
├── x_emissary_test.go
├── x_funfedi_test.go
├── x_ietf_test.go
└── x_pixelfed_test.go
├── streams
├── README.md
├── bluemonday_test.go
├── client.go
├── client_http.go
├── client_test.go
├── collection.go
├── collectionPage.go
├── context.go
├── contextEntry.go
├── context_test.go
├── documentOption.go
├── document_.go
├── document_actor.go
├── document_header.go
├── document_json.go
├── document_set.go
├── document_special.go
├── document_test.go
├── document_vocabulary.go
├── image.go
├── items.go
├── metadata.go
├── options.go
├── orderedCollection.go
├── orderedCollectionPage.go
├── range.go
├── types.go
├── uniquer.go
└── utils.go
├── test-signatures
└── main.go
├── utils.go
├── utils_test.go
├── validator
├── deleted-objects.go
├── http-lookup.go
├── http-signatures.go
├── identityProofs.go
└── result.go
└── vocab
├── README.md
├── activityTypes.go
├── actorPropertyTypes.go
├── actorTypes.go
├── contentTypes.go
├── contextTypes.go
├── coreTypes.go
├── customTypes.go
├── documentTypes.go
├── linkTypes.go
├── namespaces.go
├── objectTypes.go
├── propertyTypes.go
├── relationType.go
├── securityTypes.go
└── validators.go
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "gomod" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | target-branch: "main"
11 | schedule:
12 | interval: "daily"
13 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL Advanced"
13 |
14 | on:
15 | push:
16 | branches: [ "main", "dev" ]
17 | pull_request:
18 | branches: [ "main", "dev" ]
19 | schedule:
20 | - cron: '43 5 * * 6'
21 |
22 | jobs:
23 | analyze:
24 | name: Analyze (${{ matrix.language }})
25 | # Runner size impacts CodeQL analysis time. To learn more, please see:
26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql
27 | # - https://gh.io/supported-runners-and-hardware-resources
28 | # - https://gh.io/using-larger-runners (GitHub.com only)
29 | # Consider using larger runners or machines with greater resources for possible analysis time improvements.
30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
31 | permissions:
32 | # required for all workflows
33 | security-events: write
34 |
35 | # required to fetch internal or private CodeQL packs
36 | packages: read
37 |
38 | # only required for workflows in private repositories
39 | actions: read
40 | contents: read
41 |
42 | strategy:
43 | fail-fast: false
44 | matrix:
45 | include:
46 | - language: actions
47 | build-mode: none
48 | - language: go
49 | build-mode: autobuild
50 | - language: javascript-typescript
51 | build-mode: none
52 | # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
53 | # Use `c-cpp` to analyze code written in C, C++ or both
54 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both
55 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
56 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
57 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
58 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
59 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
60 | steps:
61 | - name: Checkout repository
62 | uses: actions/checkout@v4
63 |
64 | # Add any setup steps before running the `github/codeql-action/init` action.
65 | # This includes steps like installing compilers or runtimes (`actions/setup-node`
66 | # or others). This is typically only required for manual builds.
67 | # - name: Setup runtime (example)
68 | # uses: actions/setup-example@v1
69 |
70 | # Initializes the CodeQL tools for scanning.
71 | - name: Initialize CodeQL
72 | uses: github/codeql-action/init@v3
73 | with:
74 | languages: ${{ matrix.language }}
75 | build-mode: ${{ matrix.build-mode }}
76 | # If you wish to specify custom queries, you can do so here or in a config file.
77 | # By default, queries listed here will override any specified in a config file.
78 | # Prefix the list here with "+" to use these queries and those in the config file.
79 |
80 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
81 | # queries: security-extended,security-and-quality
82 |
83 | # If the analyze step fails for one of the languages you are analyzing with
84 | # "We were unable to automatically build your code", modify the matrix above
85 | # to set the build mode to "manual" for that language. Then modify this step
86 | # to build your code.
87 | # ℹ️ Command-line programs to run using the OS shell.
88 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
89 | - if: matrix.build-mode == 'manual'
90 | shell: bash
91 | run: |
92 | echo 'If you are using a "manual" build mode for one or more of the' \
93 | 'languages you are analyzing, replace this with the commands to build' \
94 | 'your code, for example:'
95 | echo ' make bootstrap'
96 | echo ' make release'
97 | exit 1
98 |
99 | - name: Perform CodeQL Analysis
100 | uses: github/codeql-action/analyze@v3
101 | with:
102 | category: "/language:${{matrix.language}}"
103 |
--------------------------------------------------------------------------------
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | name: Go
2 | permissions:
3 | contents: read
4 |
5 | on:
6 | push:
7 | branches: [ main, dev ]
8 | pull_request:
9 | branches: [ main, dev ]
10 |
11 | jobs:
12 |
13 | build:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout repository
17 | uses: actions/checkout@v4
18 | # with:
19 | # fetch-depth: 0
20 |
21 | - name: Set up Go
22 | uses: actions/setup-go@v5
23 | with:
24 | go-version: '1.25'
25 |
26 | - name: Test Coverage
27 | run: go test -race -coverprofile=coverage.txt -covermode=atomic -v ./...
28 |
29 | - name: Report Code Coverage
30 | uses: codecov/codecov-action@v5
31 | with:
32 | fail_ci_if_error: false
33 | flags: unittests
34 | token: ${{ secrets.CODECOV_TOKEN }}
35 | verbose: true
36 |
37 | - name: GolangCI-Lint
38 | uses: golangci/golangci-lint-action@v8
39 | with:
40 | version: latest
41 | skip-cache: true
42 |
43 | # - name: Nilaway
44 | # uses: qbaware/nilaway-action@v0
45 | # with:
46 | # package-to-scan: ./...
47 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 | .DS_Store
17 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hannibal
2 |
3 |
4 |
5 | [](http://pkg.go.dev/github.com/benpate/hannibal)
6 | [](https://github.com/benpate/hannibal/releases)
7 | [](https://github.com/benpate/hannibal/actions/workflows/go.yml)
8 | [](https://goreportcard.com/report/github.com/benpate/hannibal)
9 | [](https://codecov.io/gh/benpate/hannibal)
10 |
11 | ## Triumphant ActivityPub for Go
12 | Hannibal is an experimental ActivityPub library for Go. It's goal is to be a robust, idiomatic, and thoroughly documented ActivityPub implementation fits into your application without any magic or drama.
13 |
14 | ## DO NOT USE
15 |
16 | This project is a work-in-progress, and should NOT be used by ANYONE, for ANY PURPOSE, under ANY CIRCUMSTANCES. It is WILL BE CHANGED UNDERNEATH YOU WITHOUT NOTICE OR HESITATION, and is expressly GUARANTEED to blow up your computer, send your cat into an infinite loop, and combine your hot and cold laundry into a single cycle.
17 |
18 | There are other packages/frameworks out there that are more complete and mature. So please check out [go-fed](https://github.com/go-fed) and [go-ap](https://github.com/go-ap) before trying this.
19 |
20 |
21 | ## Packages
22 | Like the ActivityPub spec itself, Hannibal is broken into several layers:
23 |
24 | ### pub - ActivityPub client/server
25 | https://www.w3.org/TR/activitypub/
26 |
27 | This is not an ActivityPub framework, but a simple library that easily plugs into your existing app. Add ActivityPub behaviors to your existing handlers, and send ActivityPub messages to
28 |
29 | ### vocab - ActivityStreams Vocabulary
30 | https://www.w3.org/TR/activitystreams-vocabulary/
31 |
32 | The `vocab` package includes the standard ActivityStream vocabulary, including names of actions, objects and properties used in ActivityPub.
33 |
34 | ### streams - ActivityStreams data structures
35 | https://www.w3.org/TR/activitystreams-core/
36 |
37 | The `streams` package contains common data structures defined in the ActivityStreams spec, notably definitions for: `Document`, `Collection`, `OrderedCollection`, `CollectionPage`, and `OrderedCollectionPage`. These are used by ActivityPub to send and receive multiple records in one HTTP request.
38 |
39 | This package also includes a lightweight wrapper around generic data structures (like `map[string]any` and `[]any`) that makes it easy to access data structures within an ActivityStreams/JSON-LD document.
40 |
41 | ### sigs - HTTP Signatures and Digests
42 | https://datatracker.ietf.org/doc/draft-ietf-httpbis-message-signatures
43 |
44 | The `sigs` package creates and verifies HTTP signatures and Digests.
45 |
46 | ## Pull Requests Welcome
47 |
48 | This library is a work in progress, and will benefit from your experience reports, use cases, and contributions. If you have an idea for making this library better, send in a pull request. We're all in this together! 🐘
49 |
--------------------------------------------------------------------------------
/clients/hashlookup.go:
--------------------------------------------------------------------------------
1 | package clients
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/benpate/derp"
7 | "github.com/benpate/hannibal/streams"
8 | )
9 |
10 | // HashLookup is a streams.Client wrapper that searches for hash values in a document.
11 | type HashLookup struct {
12 | innerClient streams.Client
13 | }
14 |
15 | // NewHashLookup creates a fully initialized Client object
16 | func NewHashLookup(innerClient streams.Client) HashLookup {
17 | return HashLookup{
18 | innerClient: innerClient,
19 | }
20 | }
21 |
22 | // Load retrieves a document from the underlying innerClient, then searches for hash values
23 | // inside it (if required)
24 | func (client HashLookup) Load(url string, options ...any) (streams.Document, error) {
25 |
26 | // Try to find a hash in the URL
27 | baseURL, hash, found := strings.Cut(url, "#")
28 |
29 | // If there is no hash, then proceed as is.
30 | if !found {
31 | return client.innerClient.Load(url, options...)
32 | }
33 |
34 | // Otherwise, try to load the baseURL and find the hash inside that document
35 | result, err := client.innerClient.Load(baseURL, options)
36 |
37 | if err != nil {
38 | return result, err
39 | }
40 |
41 | // Search all properties at the top level of the document (not recursive)
42 | // and scan through arrays (if present) looking for an ID that matches the original URL (base + hash)
43 | for _, key := range result.MapKeys() {
44 | for property := result.Get(key); property.NotNil(); property = property.Tail() {
45 | if property.ID() == url {
46 | return property, nil
47 | }
48 | }
49 | }
50 |
51 | // Not found.
52 | return streams.NilDocument(), derp.NotFoundError("ashash.Client.Load", "Hash value not found in document", baseURL, hash, result.Value())
53 | }
54 |
55 | func (client HashLookup) Save(document streams.Document) error {
56 | return nil
57 | }
58 |
--------------------------------------------------------------------------------
/collections/README.md:
--------------------------------------------------------------------------------
1 | # Hannibal / collections
2 |
3 | This package provides channel-based tools for traversing [ActivityStreams](https://www.w3.org/TR/activitystreams-core/) collections.
4 |
5 | ```go
6 | // Retrieve a collection from the interwebs
7 | outboxCollection := streams.NewDocument("https://your-website/@your-actor/outbox")
8 |
9 | // Create a channel to iterate over the collection
10 | documentChannel := collections.Documents(outboxCollection, context.TODO().Done())
11 |
12 | // Yep. That's all there is. Now get to work.
13 | for document := range documentChannel {
14 | // do stuff.
15 | }
16 | ```
17 |
18 | ### Traversing All Documents in a Collection
19 |
20 | The `Documents()` function is probably the only function you'll need from this package. It returns a
21 | channel of all documents in a collection. It works with both
22 | [`Collections`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collection),
23 | and [`OrderedCollections`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection),
24 | regardless of whether all documents are included directly in the collection, or if they are spread
25 | across multiple pages.
26 |
27 | ### Traversing All Pages in a Collection
28 |
29 | The `Pages` function returns a channel of all pages in a collection. It can traverse both
30 | [`CollectionPage`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage)s
31 | and [`OrderedCollectionPage`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollectionpage)s
32 |
33 |
34 | ## Additional Tools
35 |
36 | The [rosetta channel](https://github.com/benpate/rosetta/tree/main/channel) package includes a number of functions for manipulating channels. For instance, if you don't want to read an actor's entire outbox, you might limit the results like this:
37 |
38 | ```go
39 | // Retrieve a collection from the interwebs
40 | outboxCollection := streams.NewDocument("https://your-website/@your-actor/outbox")
41 |
42 | // Create a "done" channel to cancel iterating over the collection
43 | var done chan struct{}
44 |
45 | // Create a channel to iterate over the collection
46 | documentChannel := collections.Documents(outboxCollection, done)
47 |
48 | // Limit will use the "done" channel to cancel iteration once we reach the limit
49 | limitedChannel := channel.Limit(10, documentChannel, done)
50 |
51 | // Now just iterate. The limitedChannel will close after 10 documents.
52 | for document := range limtedChannel {
53 | // do stuff.
54 | }
55 | ```
56 |
--------------------------------------------------------------------------------
/collections/countItems.go:
--------------------------------------------------------------------------------
1 | package collections
2 |
3 | import (
4 | "github.com/benpate/derp"
5 | "github.com/benpate/hannibal/streams"
6 | )
7 |
8 | // CountItems returns the number of items in the given collection. If the
9 | // collection includes a `TotalItems` value, then this is returned. Otherwise,
10 | // this function will iterate through all pages in the collection to count the items.
11 | func CountItems(collection streams.Document) (int, error) {
12 |
13 | const location = "hannibal.collections.CountItems"
14 |
15 | // If the collection does not exist, then there are no items.
16 | if collection.IsNil() {
17 | return 0, nil
18 | }
19 |
20 | defer func() {
21 | if err := recover(); err != nil {
22 | derp.Report(derp.Internal(location, "Recovered error", err, collection.ID()))
23 | }
24 | }()
25 |
26 | // If this is not already a "map" then load the complete document.
27 | collection = collection.LoadLink()
28 |
29 | // If the collection already reports the `TotalItems` count, then just use that.
30 | // This is the best case scenario.
31 | if totalItems := collection.TotalItems(); totalItems > 0 {
32 | return totalItems, nil
33 | }
34 |
35 | // Otherwise, we have to do your work for you. Let's start paging and counting...
36 | var result int
37 |
38 | // Retrieve each page in the collection and count the number of items it contains.
39 | for page := range RangePages(collection) {
40 |
41 | // Get the number of items in this page
42 | count := page.Items().Len()
43 |
44 | // If this page is empty, then we don't need to load any more pages
45 | if count == 0 {
46 | break
47 | }
48 |
49 | // Increment the total count
50 | result += count
51 | }
52 |
53 | // Return the number of items found
54 | return result, nil
55 | }
56 |
--------------------------------------------------------------------------------
/collections/countItems_test.go:
--------------------------------------------------------------------------------
1 | //go:build localonly
2 |
3 | package collections
4 |
5 | import (
6 | "testing"
7 |
8 | "github.com/benpate/hannibal/streams"
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func TestCountItems(t *testing.T) {
13 |
14 | do := func(url string) {
15 | client := streams.NewDefaultClient()
16 | collection, err := client.Load(url)
17 | require.Nil(t, err)
18 |
19 | count, err := CountItems(collection)
20 | require.Nil(t, err)
21 | t.Logf("%s: %d", url, count)
22 | }
23 |
24 | do("https://mastodon.social/users/benpate/outbox")
25 | // do("https://social.wizard.casa/users/benpate/outbox")
26 | // do("https://social.wizard.casa/users/benpate/followers")
27 | do("https://infosec.exchange/users/tinker/statuses/115276098511707960/likes")
28 | }
29 |
--------------------------------------------------------------------------------
/collections/iteratorOption.go:
--------------------------------------------------------------------------------
1 | package collections
2 |
3 | import (
4 | "github.com/benpate/hannibal/streams"
5 | "github.com/benpate/rosetta/channel"
6 | )
7 |
8 | type IteratorOption func(channel <-chan streams.Document, done chan struct{}) <-chan streams.Document
9 |
10 | func WithLimit(depth int) IteratorOption {
11 | return func(ch <-chan streams.Document, done chan struct{}) <-chan streams.Document {
12 | return channel.Limit(depth, ch, done)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/collections/range-documents.go:
--------------------------------------------------------------------------------
1 | package collections
2 |
3 | import (
4 | "iter"
5 |
6 | "github.com/benpate/hannibal/streams"
7 | )
8 |
9 | func RangeDocuments(collection streams.Document) iter.Seq[streams.Document] {
10 |
11 | return func(yield func(streams.Document) bool) {
12 |
13 | // Loop through every page in the collection
14 | for page := range RangePages(collection) {
15 |
16 | // Loop through all items in the page
17 | for items := page.Items(); items.NotNil(); items = items.Tail() {
18 |
19 | if !yield(items.Head()) {
20 | return
21 | }
22 | }
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/collections/range-documentsBefore.go:
--------------------------------------------------------------------------------
1 | package collections
2 |
3 | import (
4 | "iter"
5 | "math"
6 | "time"
7 |
8 | "github.com/benpate/hannibal/streams"
9 | )
10 |
11 | func RangeDocumentsBefore(iterator iter.Seq[streams.Document], limit int64) iter.Seq[streams.Document] {
12 |
13 | return func(yield func(streams.Document) bool) {
14 |
15 | // Empty limit actually means "the end of time"
16 | if limit == 0 {
17 | limit = math.MaxInt64
18 | }
19 |
20 | // Convert to a time.Time object for comparison
21 | limitTime := time.Unix(limit, 0)
22 |
23 | // Scan through the range of documents
24 | for document := range iterator {
25 |
26 | // Filter out documents that are equal or after the "before" date
27 | if document.Published().After(limitTime) {
28 | continue
29 | }
30 |
31 | if !yield(document) {
32 | return
33 | }
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/collections/range-documents_test.go:
--------------------------------------------------------------------------------
1 | //go:build localonly
2 |
3 | package collections
4 |
5 | import (
6 | "testing"
7 |
8 | "github.com/benpate/hannibal/streams"
9 | "github.com/davecgh/go-spew/spew"
10 | )
11 |
12 | func TestDocuments(t *testing.T) {
13 |
14 | doc := streams.NewDocument("https://mastodon.social/@benpate")
15 | outbox := doc.Outbox()
16 |
17 | items := RangeDocuments(outbox)
18 |
19 | index := 1
20 | for item := range items {
21 | spew.Dump(index)
22 | spew.Dump(item.Published())
23 | index++
24 |
25 | if index > 100 {
26 | break // okay, we get it.. you can load lots of documents...
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/collections/range-pages.go:
--------------------------------------------------------------------------------
1 | // Package iterator provides utilities for iterating through remote collections (represented as streams.Documents)
2 | package collections
3 |
4 | import (
5 | "iter"
6 |
7 | "github.com/benpate/derp"
8 | "github.com/benpate/hannibal/streams"
9 | )
10 |
11 | func RangePages(collection streams.Document) iter.Seq[streams.Document] {
12 |
13 | const location = "hannibal.collections.Pages"
14 |
15 | return func(yield func(streams.Document) bool) {
16 |
17 | var err error
18 |
19 | // emptyPage is used to prevent WriteFreely-style infinite loops
20 | var emptyPage bool
21 |
22 | // If this is a collection header, then try to load the first page of results
23 | if firstPage := collection.First(); firstPage.NotNil() {
24 | collection, err = firstPage.Load()
25 |
26 | if err != nil {
27 | derp.Report(derp.Wrap(err, location, "Error loading first page", collection))
28 | return
29 | }
30 | }
31 |
32 | // As long as we have a valid collection...
33 | for collection.NotNil() {
34 |
35 | // Send the collection to the caller
36 | if !yield(collection) {
37 | return
38 | }
39 |
40 | // Look for the next page in the collection (if available)
41 | collection = collection.Next()
42 |
43 | // Try to load it and continue the loop.
44 | collection, err = collection.Load()
45 |
46 | if err != nil {
47 | derp.Report(derp.Wrap(err, location, "Error loading first page", collection))
48 | return
49 | }
50 |
51 | // If this document is an empty page, then try to prevent
52 | // WriteFreely-style infinite loops.
53 | if collection.Items().Len() == 0 {
54 |
55 | // If we've already seen ONE empty page, then exit.
56 | if emptyPage {
57 | return
58 | }
59 |
60 | // Otherwise, set the emptyPage flag so we don't loop indefinitely.
61 | emptyPage = true
62 | }
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/collections/range-pages_test.go:
--------------------------------------------------------------------------------
1 | //go:build localonly
2 |
3 | package collections
4 |
5 | import (
6 | "testing"
7 |
8 | "github.com/benpate/hannibal/streams"
9 | "github.com/davecgh/go-spew/spew"
10 | )
11 |
12 | func TestPages(t *testing.T) {
13 |
14 | doc := streams.NewDocument("https://mastodon.social/@benpate")
15 | outbox := doc.Outbox()
16 |
17 | pages := RangePages(outbox)
18 |
19 | index := 1
20 | for page := range pages {
21 | spew.Dump(index)
22 | spew.Dump(page.ID())
23 | index++
24 |
25 | if index > 16 {
26 | break // okay, we get it.. you can load lots of pages.
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | # Hannibal / example
2 |
3 | This is a minimal app that demonstrates how to use the Hannibal library to implement ActivityPub.
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/benpate/hannibal
2 |
3 | go 1.24.0
4 |
5 | require (
6 | github.com/benpate/derp v0.34.0
7 | github.com/benpate/domain v0.2.9
8 | github.com/benpate/re v0.3.5
9 | github.com/benpate/remote v0.17.9
10 | github.com/benpate/rosetta v0.25.21
11 | github.com/davecgh/go-spew v1.1.1
12 | github.com/microcosm-cc/bluemonday v1.0.27
13 | github.com/rs/zerolog v1.34.0
14 | github.com/stretchr/testify v1.11.1
15 | )
16 |
17 | require (
18 | github.com/aymerick/douceur v0.2.0 // indirect
19 | github.com/benpate/exp v0.8.7 // indirect
20 | github.com/benpate/turbine v0.4.2 // indirect
21 | github.com/gorilla/css v1.0.1 // indirect
22 | github.com/mattn/go-colorable v0.1.14 // indirect
23 | github.com/mattn/go-isatty v0.0.20 // indirect
24 | github.com/pmezard/go-difflib v1.0.0 // indirect
25 | golang.org/x/net v0.46.0 // indirect
26 | golang.org/x/sys v0.37.0 // indirect
27 | gopkg.in/yaml.v3 v3.0.1 // indirect
28 | )
29 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
2 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
3 | github.com/benpate/derp v0.34.0 h1:Eg54jS1g6IRfdUHVCm3Fp77UsqEPwzHbc8losID1HwI=
4 | github.com/benpate/derp v0.34.0/go.mod h1:eWyOubqTrcUKVPnBoQBw9J9GdpCxupkMO56mGGvjCtI=
5 | github.com/benpate/domain v0.2.9 h1:2LLxNtXG9cukk3uTR83xDS+b47EQKT4rujZQNW4LmSE=
6 | github.com/benpate/domain v0.2.9/go.mod h1:L4izRt2ma1OJXXtN90NqC4IvBJScyG7WzVCm/QzSYcc=
7 | github.com/benpate/exp v0.8.7 h1:uYhBl0nin23tvGi5r/ki9bf7v4mulz9LKLdlIb3g3U8=
8 | github.com/benpate/exp v0.8.7/go.mod h1:OPDLAVhPZvz/G43bX3JFAEP02OTIRvZNwNRduV44RoU=
9 | github.com/benpate/re v0.3.5 h1:7v4jQfrzCqnhy1+6rG+AaJUyxh4R7atLJEZN3vhQtNA=
10 | github.com/benpate/re v0.3.5/go.mod h1:8qidRUQ9+HgSU0ETyzWSXqajR8D/ZZvMXMAs9rxlJWc=
11 | github.com/benpate/remote v0.17.9 h1:JCZPhcR4UfufB3GH3XDurxAZB20lzftl30Xr7JPLLcg=
12 | github.com/benpate/remote v0.17.9/go.mod h1:T3LItPkJsY5pAVaMCiRsTqA5DkOsPGGQYgCfOrCcJk0=
13 | github.com/benpate/rosetta v0.25.21 h1:qyr5xKXRh7EKD6YHNFrhAwcrreQ4AOxBALvvlEmYJAs=
14 | github.com/benpate/rosetta v0.25.21/go.mod h1:KL+6m+UoiIsYr7LAxINUHKQE3M4T31ZtJ5U+aLAm7eQ=
15 | github.com/benpate/turbine v0.4.2 h1:b2lHLffY9SP6Ota89pNfdUAi369toNKcwVfa1CjY1tI=
16 | github.com/benpate/turbine v0.4.2/go.mod h1:W2rewu6JWl2gE6FeiUY6AsAzeNuq/n7bHj7XaKQzH5Y=
17 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
18 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
19 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
20 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
21 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
22 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
23 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
24 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
25 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
26 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
27 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
28 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
29 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
30 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
31 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
32 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
33 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
34 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
35 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
36 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
37 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
38 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
39 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
40 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
41 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
42 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
43 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
44 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
45 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
46 | golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
47 | golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
48 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
49 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
50 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
51 | golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
52 | golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
53 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
54 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
55 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
56 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
57 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
58 |
--------------------------------------------------------------------------------
/inbox/README.md:
--------------------------------------------------------------------------------
1 | # Hannibal / inbox
2 |
3 | The Inbox library gives you: 1) an ActivityPub inbox handler that parses and validates incoming ActivityPub messages, and 2) a router that identifies messages by their type and object, and routes them to the correct business logic in your application
4 |
5 | ``` golang
6 |
7 |
8 | // Set up handlers for different kinds of activities and documents
9 | activityHandler := inbox.NewRouter[CustomContextType]()
10 |
11 | // Here's a handler to accept Create/Note messages
12 | activityHandler.Add(vocab.ActivityTypeCreate, vocab.ObjectTypeNote, func(context CustomContextType, activity streams.Document) error {
13 | // do something with the activity
14 | })
15 |
16 | // You can do wildcards too. Here's a handler to accept
17 | // Follow/Any messages
18 | activityHandler.Add(vocab.ActivityTypeFollow, vocab.Any, func(context CustomContextType, activity streams.Document) error {
19 | // do something with the follow request.
20 | // remember to send an "Accept" message back to the sender
21 | })
22 |
23 | // Here's a catch-all handler that receives any uncaught messages
24 | activityHandler.Add(vocab.Any, vocab.Any, func(context CustomContextType, activity streams.Document) error {
25 | // do something with this activity
26 | })
27 |
28 | // Add routes to your web server
29 | myAppRouter.POST("/my/inbox",func (r *http.Request, w *http.Response) {
30 |
31 | // Parse and validate the posted activity
32 | activity, err := pub.ReceiveRequest(r)
33 |
34 | // Handle errors however you like
35 | if err != nil {
36 | ...
37 | }
38 |
39 | context := // create custom "Context" value for this request
40 |
41 | // Pass the activity to the activityHandler, that will figure
42 | // out what kind of activity/object we have and pass it to the
43 | // previously registered handler function
44 | if err := activityHandler.Handle(context, actitity); err != nil {
45 | // do something with the error
46 | }
47 |
48 | // Success!
49 | }
50 |
51 | ```
--------------------------------------------------------------------------------
/inbox/option.go:
--------------------------------------------------------------------------------
1 | package inbox
2 |
3 | type Option func(*ReceiveConfig)
4 |
5 | func WithValidators(validators ...Validator) Option {
6 | return func(config *ReceiveConfig) {
7 | config.Validators = validators
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/inbox/receive.go:
--------------------------------------------------------------------------------
1 | package inbox
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "net/http/httputil"
8 |
9 | "github.com/benpate/derp"
10 | "github.com/benpate/hannibal/streams"
11 | "github.com/benpate/hannibal/validator"
12 | "github.com/benpate/hannibal/vocab"
13 | "github.com/benpate/re"
14 | "github.com/rs/zerolog/log"
15 | )
16 |
17 | // ReceiveRequest reads an incoming HTTP request and returns a parsed and validated ActivityPub activity
18 | func ReceiveRequest(request *http.Request, client streams.Client, options ...Option) (document streams.Document, err error) {
19 |
20 | const location = "hannibal.pub.ReceiveRequest"
21 |
22 | config := NewReceiveConfig(options...)
23 |
24 | // Try to read the body from the request
25 | body, err := re.ReadRequestBody(request)
26 |
27 | if err != nil {
28 | return streams.NilDocument(), derp.Wrap(err, location, "Error reading body from request")
29 | }
30 |
31 | // Try to retrieve the object from the buffer
32 | document = streams.NilDocument(streams.WithClient(client))
33 |
34 | if err := json.Unmarshal(body, &document); err != nil {
35 | log.Err(err).Msg("Hannibal Inbox: Error Unmarshalling JSON")
36 | return streams.NilDocument(), derp.Wrap(err, location, "Error unmarshalling JSON body into ActivityPub document")
37 | }
38 |
39 | // Validate the document using injected Validators
40 | isValid := validateRequest(request, &document, config.Validators)
41 |
42 | if canDebug() && document.Type() != vocab.ActivityTypeDelete {
43 | requestBytes, _ := httputil.DumpRequest(request, true)
44 |
45 | fmt.Println("")
46 | fmt.Println("Begin: Hannibal ReceiveRequest -----------")
47 | fmt.Println(string(requestBytes))
48 | fmt.Println("------------------------------------------")
49 | fmt.Println("")
50 | }
51 |
52 | // Log the request
53 | if !isValid {
54 | log.Trace().Err(err).Msg("Hannibal Inbox: Received document is not valid")
55 | return streams.NilDocument(), derp.UnauthorizedError(location, "Cannot validate received document", document.Value())
56 | }
57 |
58 | // Return the parsed document to the caller (vöïlä!)
59 | return document, nil
60 | }
61 |
62 | func validateRequest(request *http.Request, document *streams.Document, validators []Validator) bool {
63 |
64 | // Run each validator
65 | for _, v := range validators {
66 |
67 | switch v.Validate(request, document) {
68 |
69 | case validator.ResultInvalid:
70 | return false
71 |
72 | case validator.ResultValid:
73 | return true
74 |
75 | }
76 |
77 | // Fall through "ResultUnknown"
78 | // means continue the loop.
79 | }
80 |
81 | // If no validators can actually validate the document, then validation fails
82 | return false
83 | }
84 |
--------------------------------------------------------------------------------
/inbox/receiveConfig.go:
--------------------------------------------------------------------------------
1 | package inbox
2 |
3 | import "github.com/benpate/hannibal/validator"
4 |
5 | // ReceiveConfig is a configuration object for the `ReceiveRequest` function.
6 | type ReceiveConfig struct {
7 | Validators []Validator
8 | }
9 |
10 | // NewReceiveConfig creates a new ReceiveConfig object with default settings,
11 | // and applies any provided options to override the defaults.
12 | func NewReceiveConfig(options ...Option) ReceiveConfig {
13 |
14 | result := ReceiveConfig{
15 | Validators: []Validator{
16 |
17 | // TODO: check Object Integrity Proofs?
18 |
19 | // checks HTTP signatures
20 | validator.NewHTTPSig(),
21 |
22 | // checks if objects have been deleted
23 | validator.NewDeletedObject(),
24 |
25 | // HTTP Lookup to confirm that the object exists
26 | // validator.NewHTTPLookup(),
27 | },
28 | }
29 |
30 | for _, option := range options {
31 | option(&result)
32 | }
33 |
34 | return result
35 | }
36 |
--------------------------------------------------------------------------------
/inbox/receive_test.go:
--------------------------------------------------------------------------------
1 | //go:build localonly
2 |
3 | package inbox
4 |
5 | import (
6 | "bufio"
7 | "net/http"
8 | "strings"
9 | "testing"
10 |
11 | "github.com/benpate/hannibal/streams"
12 | "github.com/davecgh/go-spew/spew"
13 | "github.com/stretchr/testify/require"
14 | )
15 |
16 | func TestReceive(t *testing.T) {
17 |
18 | httpReader := strings.NewReader(`POST /@66beb0b36afe0012604c5467/pub/inbox HTTP/1.1
19 | Host: bandwagon.fm
20 | Accept-Encoding: gzip, br
21 | Cdn-Loop: cloudflare; loops=1
22 | Content-Length: 254
23 | Content-Type: application/activity+json
24 | Date: Sat, 28 Sep 2024 13:36:31 GMT
25 | Digest: SHA-256=XMfWai4fSQ5v1fjYoSzTOy4IRa6utDygHVXXYbM0/JM=
26 | Do-Connecting-Ip: 146.255.56.82
27 | Signature: keyId="https://climatejustice.social/users/benpate#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="R6oGuDCaNIMq0J/j3y5dW5NybHHqSYZAdw4b2pqqhnK/m/uLi2pQKaT1ao6IBwGJukN1CZKXxkUxYZwSozi5bVMg4Z27sCGir1enerYV5tEsz0Oafoa1gxQBlcgHx7lCZhuFNpeqi9CAIyToUayHn3NFhHmvIKFz61PtBAuW64VRWJ6dx/jsFJsytkmzfi+vQCKYGUGMxHIsL1TR0rwUTU5vwfy9PbCNuC1O/3crR8OICdClKoS+1as08Qsx8oEsCSBQb+M1bVsLycQ/6M+hYmu5Qu8wS2pzeOq3LvavwqpXqX6rPQ2kHgToNdtiErZgFsFUgwIL7vRPI5oP0CfpAQ=="
28 | User-Agent: http.rb/5.1.1 (Mastodon/4.2.12-stable+ff1; +https://climatejustice.social/)
29 | X-Forwarded-For: 146.255.56.82,108.162.221.54
30 | X-Forwarded-Proto: https
31 |
32 | {"@context":"https://www.w3.org/ns/activitystreams","id":"https://climatejustice.social/1b888e76-d22e-445e-8264-11d2c9bcc46f","type":"Follow","actor":"https://climatejustice.social/users/benpate","object":"https://bandwagon.fm/@66beb0b36afe0012604c5467"}
33 | `)
34 |
35 | client := streams.NewDefaultClient()
36 |
37 | req, err := http.ReadRequest(bufio.NewReader(httpReader))
38 | require.Nil(t, err)
39 |
40 | document, err := ReceiveRequest(req, client)
41 | require.Nil(t, err)
42 |
43 | spew.Dump(document.Value())
44 | }
45 |
--------------------------------------------------------------------------------
/inbox/router.go:
--------------------------------------------------------------------------------
1 | package inbox
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 |
7 | "github.com/benpate/derp"
8 | "github.com/benpate/hannibal/property"
9 | "github.com/benpate/hannibal/streams"
10 | "github.com/benpate/hannibal/vocab"
11 | "github.com/rs/zerolog/log"
12 | )
13 |
14 | // Router is a simple object that routes incoming ActivityPub activities to the appropriate handler
15 | type Router[T any] struct {
16 | routes map[string]RouteHandler[T]
17 | }
18 |
19 | // RouteHandler is a function that handles a specific type of ActivityPub activity.
20 | // RouteHandlers are registered with the Router object along with the names of the activity
21 | // types that they correspond to.
22 | type RouteHandler[T any] func(context T, activity streams.Document) error
23 |
24 | // NewRouter creates a new Router object
25 | func NewRouter[T any]() Router[T] {
26 | result := Router[T]{
27 | routes: make(map[string]RouteHandler[T]),
28 | }
29 |
30 | return result
31 | }
32 |
33 | // Add puts a new route to the router. You can use "*" as a wildcard for
34 | // either the activityType or objectType. The Handler method tries to match
35 | // handlers from most specific to least specific.
36 | // activity/object
37 | // activity/*
38 | // */object
39 | // */*
40 | //
41 | // For performance reasons, this function is not thread-safe.
42 | // So, you should add all routes before starting the server, for
43 | // instance, in your app's `init` functions.
44 | func (router *Router[T]) Add(activityType string, objectType string, routeHandler RouteHandler[T]) {
45 | router.routes[activityType+"/"+objectType] = routeHandler
46 | }
47 |
48 | // Handle takes an ActivityPub activity and routes it to the appropriate handler
49 | func (router *Router[T]) Handle(context T, activity streams.Document) error {
50 |
51 | const location = "hannibal.inbox.Router.Handle"
52 |
53 | activityType := activity.Type()
54 |
55 | // If this is a Document (not an Activity) then wrap it in
56 | // an implicit "Create" activity before routing.
57 | if vocab.ValidateActivityType(activityType) == vocab.Unknown {
58 |
59 | newValue := property.Map{
60 | vocab.AtContext: activity.AtContext(),
61 | vocab.PropertyID: activity.ID(),
62 | vocab.PropertyActor: activity.Actor(),
63 | vocab.PropertyType: vocab.ActivityTypeCreate,
64 | vocab.PropertyObject: activity.Value(),
65 | }
66 |
67 | activity.SetValue(newValue)
68 | activityType = vocab.ActivityTypeCreate
69 | }
70 |
71 | // Log all incoming activity... except delete messages because Mastodon is way too chatty
72 | if canDebug() && (activityType != vocab.ActivityTypeDelete) {
73 | log.Debug().Str("activity", activityType).Str("type", activity.Object().Type()).Msg("Hannibal Router: Received Message")
74 |
75 | if canTrace() {
76 | marshalled, _ := json.MarshalIndent(activity.Value(), "", " ")
77 | fmt.Println(string(marshalled))
78 | }
79 | }
80 |
81 | // Loop through all object Type values (though there's usually just one) to find a matching route
82 | for _, objectType := range activity.Object().Types() {
83 |
84 | if routeHandler, ok := router.routes[activityType+"/"+objectType]; ok {
85 | log.Trace().Str("type", activityType+"/"+objectType).Msg("Hannibal Router: route matched.")
86 | return routeHandler(context, activity)
87 | }
88 |
89 | if routeHandler, ok := router.routes[vocab.Any+"/"+objectType]; ok {
90 | log.Trace().Str("type", "*/"+objectType).Msg("Hannibal Router: route matched.")
91 | return routeHandler(context, activity)
92 | }
93 | }
94 |
95 | if routeHandler, ok := router.routes[activityType+"/"+vocab.Any]; ok {
96 | log.Trace().Str("type", activityType+"/*").Msg("Hannibal Router: route matched.")
97 | return routeHandler(context, activity)
98 | }
99 |
100 | if routeHandler, ok := router.routes[vocab.Any+"/"+vocab.Any]; ok {
101 | log.Trace().Str("type", "*/*").Msg("Hannibal Router: route matched.")
102 | return routeHandler(context, activity)
103 | }
104 |
105 | return derp.BadRequestError(location, "No route found for activity", activityType, activity.Object().Types(), activity.Value())
106 | }
107 |
--------------------------------------------------------------------------------
/inbox/utils.go:
--------------------------------------------------------------------------------
1 | package inbox
2 |
3 | import (
4 | "github.com/rs/zerolog"
5 | )
6 |
7 | // canInfo returns TRUE if zerolog is configured to allow Info logs
8 | // nolint:unused
9 | func canInfo() bool {
10 | return canLog(zerolog.InfoLevel)
11 | }
12 |
13 | // canDebug returns TRUE if zerolog is configured to allow Debug logs
14 | // nolint:unused
15 | func canDebug() bool {
16 | return canLog(zerolog.DebugLevel)
17 | }
18 |
19 | // canTrace returns TRUE if zerolog is configured to allow Trace logs
20 | // nolint:unused
21 | func canTrace() bool {
22 | return canLog(zerolog.TraceLevel)
23 | }
24 |
25 | // canLog is a silly zerolog helper that returns TRUE
26 | // if the provided log level would be allowed
27 | // (based on the global log level).
28 | // This makes it easier to execute expensive code conditionally,
29 | // for instance: marshalling a JSON object for logging.
30 | func canLog(level zerolog.Level) bool {
31 | return zerolog.GlobalLevel() <= level
32 | }
33 |
--------------------------------------------------------------------------------
/inbox/utils_test.go:
--------------------------------------------------------------------------------
1 | package inbox
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/rs/zerolog"
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | func TestCanTrace(t *testing.T) {
11 | zerolog.SetGlobalLevel(zerolog.TraceLevel)
12 | require.True(t, canTrace())
13 | zerolog.SetGlobalLevel(zerolog.DebugLevel)
14 | require.False(t, canTrace())
15 | }
16 |
17 | func TestCanDebug(t *testing.T) {
18 | zerolog.SetGlobalLevel(zerolog.TraceLevel)
19 | require.True(t, canDebug())
20 | zerolog.SetGlobalLevel(zerolog.DebugLevel)
21 | require.True(t, canDebug())
22 | zerolog.SetGlobalLevel(zerolog.InfoLevel)
23 | require.False(t, canDebug())
24 | }
25 |
26 | func TestCanInfo(t *testing.T) {
27 | zerolog.SetGlobalLevel(zerolog.TraceLevel)
28 | require.True(t, canDebug())
29 | zerolog.SetGlobalLevel(zerolog.DebugLevel)
30 | require.True(t, canDebug())
31 | zerolog.SetGlobalLevel(zerolog.InfoLevel)
32 | require.True(t, canInfo())
33 | zerolog.SetGlobalLevel(zerolog.WarnLevel)
34 | require.False(t, canInfo())
35 | }
36 |
--------------------------------------------------------------------------------
/inbox/validator.go:
--------------------------------------------------------------------------------
1 | package inbox
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/benpate/hannibal/streams"
7 | "github.com/benpate/hannibal/validator"
8 | )
9 |
10 | // Validator interface wraps the Validate method, which identifies whether a document
11 | // received in an actor's inbox is valid or not. Multiple validators can be stacked
12 | // to validate a document, so if one validator returns `false`, the document is not
13 | // necessary invalid. It just can't be validated by this one validator.
14 | type Validator interface {
15 |
16 | // Validate checks incoming HTTP requests for validity. If a document is
17 | // valid, it returns `ResultValid`. If the Validator cannot validate this
18 | // document, it returns `ResultUnknown`. If the Validator can say with
19 | // certainty that the document is invalid, it returns `ResultInvalid`.
20 | Validate(*http.Request, *streams.Document) validator.Result
21 | }
22 |
--------------------------------------------------------------------------------
/meta/logo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/benpate/hannibal/464facfc019b5a3aab760b3a2dd71595268dab9e/meta/logo.jpg
--------------------------------------------------------------------------------
/meta/sigs.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/benpate/hannibal/464facfc019b5a3aab760b3a2dd71595268dab9e/meta/sigs.jpg
--------------------------------------------------------------------------------
/outbox/README.md:
--------------------------------------------------------------------------------
1 | # Hannibal / outbox
2 |
3 | Outbox mimics an ActivityPub outbox. Passing a document to the outbox will
4 | use an outbound retry-queue to deliver it to all recipients in `to`, `cc`,
5 | and `bto` fields.
6 |
7 | ```go
8 |
9 | // Get an Actor's outbox
10 | actor := outbox.New(myActor, outbox.WithClient(myClient), outbox.WithQueue(myQueue))
11 |
12 | // The document is the ActivityPub document you're sending
13 | document := map[string]any{
14 | "@context": vocab.ContextTypeDefault.
15 | "type": vocab.ActivityTypeCreate,
16 | "actor": actor.ActorID,
17 | "object": map[string]any{
18 | "type": "Note",
19 | "name": "A new note",
20 | },
21 | }
22 |
23 | // Send a document via the Actor's outbox
24 | if err := actor.Send(document); err != nil {
25 | derp.Report(err)
26 | }
27 |
28 | ```
29 |
--------------------------------------------------------------------------------
/outbox/actor-.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "crypto"
5 | "iter"
6 |
7 | "github.com/benpate/hannibal/streams"
8 | )
9 |
10 | // Actor represents an ActivityPub actor that can send ActivityPub messages
11 | // https://www.w3.org/TR/activitypub/#actors
12 | type Actor struct {
13 |
14 | // Required values passed to NewActor function
15 | actorID string
16 | privateKey crypto.PrivateKey
17 |
18 | // Optional values set via With() options
19 | publicKeyID string
20 | client streams.Client
21 | followers iter.Seq[string]
22 | // TODO: Restore Queue:: queue *queue.Queue
23 | }
24 |
25 | /******************************************
26 | * Lifecycle Methods
27 | ******************************************/
28 |
29 | // NewActor returns a fully initialized Actor object, and applies optional settings as provided
30 | func NewActor(actorID string, privateKey crypto.PrivateKey, options ...ActorOption) Actor {
31 |
32 | // Set Default Values
33 | result := Actor{
34 | actorID: actorID,
35 | publicKeyID: actorID + "#main-key",
36 | privateKey: privateKey,
37 | followers: func(yield func(string) bool) {}, // Default is an empty iterator
38 | }
39 |
40 | // Apply additional options
41 | result.With(options...)
42 | return result
43 | }
44 |
45 | // With applies one or more options to an Actor
46 | func (actor *Actor) With(options ...ActorOption) {
47 | for _, option := range options {
48 | option(actor)
49 | }
50 | }
51 |
52 | func (actor *Actor) ActorID() string {
53 | return actor.actorID
54 | }
55 |
56 | /******************************************
57 | * Internal / Helper Methods
58 | ******************************************/
59 |
60 | // getClient returns the hannibal Client to use when retrieving
61 | // JSON-LD data. If the Actor does not include a custom client,
62 | // then a default HTTP-only client is used instead.
63 | func (actor *Actor) getClient() streams.Client {
64 |
65 | if actor.client != nil {
66 | return actor.client
67 | }
68 |
69 | return streams.NewDefaultClient()
70 | }
71 |
--------------------------------------------------------------------------------
/outbox/actor-send-accept.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "github.com/benpate/hannibal/streams"
5 | "github.com/benpate/hannibal/vocab"
6 | "github.com/benpate/rosetta/mapof"
7 | "github.com/rs/zerolog/log"
8 | )
9 |
10 | // SendAccept sends an "Accept" message to the recipient
11 | // actor: The Actor that is sending the request
12 | // activity: The activity that has been accepted (likely a "Follow" request)
13 | func (actor *Actor) SendAccept(acceptID string, activity streams.Document) {
14 |
15 | if canDebug() {
16 | log.Debug().Msg("outbox.Actor.SendAccept: " + acceptID)
17 | }
18 |
19 | message := mapof.Any{
20 | vocab.AtContext: vocab.ContextTypeActivityStreams,
21 | vocab.PropertyID: acceptID,
22 | vocab.PropertyType: vocab.ActivityTypeAccept,
23 | vocab.PropertyActor: actor.actorID,
24 | vocab.PropertyObject: activity.Map(),
25 | }
26 |
27 | recipients := activity.Actor().RangeIDs()
28 |
29 | actor.Send(message, recipients)
30 | }
31 |
--------------------------------------------------------------------------------
/outbox/actor-send-announce.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/benpate/hannibal"
7 | "github.com/benpate/hannibal/streams"
8 | "github.com/benpate/hannibal/vocab"
9 | "github.com/benpate/rosetta/mapof"
10 | "github.com/rs/zerolog/log"
11 | )
12 |
13 | // SendAccept sends an "Announce" message to the recipient
14 | // activity: The activity that is being announced
15 | func (actor *Actor) SendAnnounce(announceID string, object streams.Document) {
16 |
17 | if canDebug() {
18 | log.Debug().Msg("outbox.Actor.SendAnnounce: " + announceID)
19 | }
20 |
21 | message := mapof.Any{
22 | vocab.AtContext: vocab.ContextTypeActivityStreams,
23 | vocab.PropertyType: vocab.ActivityTypeAnnounce,
24 | vocab.PropertyID: announceID,
25 | vocab.PropertyActor: actor.actorID,
26 | vocab.PropertyObject: object.Map(),
27 | vocab.PropertyPublished: hannibal.TimeFormat(time.Now()),
28 | }
29 |
30 | actor.Send(message, actor.followers, object.RangeAddressees())
31 | }
32 |
--------------------------------------------------------------------------------
/outbox/actor-send-create.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/benpate/hannibal"
7 | "github.com/benpate/hannibal/streams"
8 | "github.com/benpate/hannibal/vocab"
9 | "github.com/benpate/rosetta/mapof"
10 | "github.com/rs/zerolog/log"
11 | )
12 |
13 | // SendCreate sends an "Create" message to the recipient
14 | // actor: The Actor that is sending the request
15 | // activity: The activity that has been created (such as a "Note" or "Article")
16 | // recipient: The profile of the message recipient
17 | func (actor *Actor) SendCreate(document streams.Document) {
18 |
19 | if canDebug() {
20 | log.Debug().Msg("outbox.Actor.SendCreate: " + document.ID())
21 | }
22 |
23 | message := mapof.Any{
24 | vocab.AtContext: vocab.ContextTypeActivityStreams,
25 | vocab.PropertyType: vocab.ActivityTypeCreate,
26 | vocab.PropertyActor: actor.actorID,
27 | vocab.PropertyObject: document.Map(),
28 | vocab.PropertyPublished: hannibal.TimeFormat(time.Now()),
29 | }
30 |
31 | actor.Send(
32 | message,
33 | document.RangeAddressees(),
34 | document.RangeInReplyTo(),
35 | actor.followers,
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/outbox/actor-send-delete.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/benpate/hannibal"
7 | "github.com/benpate/hannibal/streams"
8 | "github.com/benpate/hannibal/vocab"
9 | "github.com/benpate/rosetta/mapof"
10 | "github.com/rs/zerolog/log"
11 | )
12 |
13 | // SendDelete sends an "Delete" message to the recipient
14 | // actor: The Actor that is sending the request
15 | // activity: The activity that has been deleted
16 | // recipient: The ActivityStream profile of the message recipient
17 | func (actor *Actor) SendDelete(document streams.Document) {
18 |
19 | if canDebug() {
20 | log.Debug().Msg("outbox.Actor.SendDelete: " + document.Object().ID())
21 | }
22 |
23 | message := mapof.Any{
24 | vocab.AtContext: vocab.ContextTypeActivityStreams,
25 | vocab.PropertyType: vocab.ActivityTypeDelete,
26 | vocab.PropertyActor: actor.actorID,
27 | vocab.PropertyObject: document.Object().Map(),
28 | vocab.PropertyPublished: hannibal.TimeFormat(time.Now()),
29 | }
30 |
31 | actor.Send(message, document.RangeAddressees(), actor.followers)
32 | }
33 |
--------------------------------------------------------------------------------
/outbox/actor-send-dislike.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/benpate/hannibal"
7 | "github.com/benpate/hannibal/streams"
8 | "github.com/benpate/hannibal/vocab"
9 | "github.com/benpate/rosetta/mapof"
10 | "github.com/rs/zerolog/log"
11 | )
12 |
13 | // SendDislike sends an "Dislike" message to the recipient
14 | // activity: The activity that is being announced
15 | func (actor *Actor) SendDislike(dislikeID string, object streams.Document) {
16 |
17 | if canDebug() {
18 | log.Debug().Msg("outbox.Actor.SendDislike: " + dislikeID)
19 | }
20 |
21 | message := mapof.Any{
22 | vocab.AtContext: vocab.ContextTypeActivityStreams,
23 | vocab.PropertyType: vocab.ActivityTypeDislike,
24 | vocab.PropertyID: dislikeID,
25 | vocab.PropertyActor: actor.actorID,
26 | vocab.PropertyObject: object.Map(),
27 | vocab.PropertyPublished: hannibal.TimeFormat(time.Now()),
28 | }
29 |
30 | actor.Send(message, actor.followers, object.RangeAddressees())
31 | }
32 |
--------------------------------------------------------------------------------
/outbox/actor-send-follow.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/benpate/hannibal"
7 | "github.com/benpate/hannibal/vocab"
8 | "github.com/benpate/rosetta/mapof"
9 | "github.com/rs/zerolog/log"
10 | )
11 |
12 | // SendFollow sends a "Follow" request to the recipient
13 | // actor: The Actor that is sending the request
14 | // followID: The unique ID of this request
15 | // recipient: The ActivityStream profile of the Actor that is being followed
16 | func (actor *Actor) SendFollow(followID string, remoteActorID string) {
17 |
18 | if canDebug() {
19 | log.Debug().Msg("outbox.Actor.SendFollow: " + followID)
20 | }
21 |
22 | // Build the ActivityStream "Follow" request
23 | message := mapof.Any{
24 | vocab.AtContext: vocab.ContextTypeActivityStreams,
25 | vocab.PropertyID: followID,
26 | vocab.PropertyType: vocab.ActivityTypeFollow,
27 | vocab.PropertyActor: actor.actorID,
28 | vocab.PropertyObject: remoteActorID,
29 | vocab.PropertyPublished: hannibal.TimeFormat(time.Now()),
30 | }
31 |
32 | // Send the request
33 | actor.Send(message, makeIterator(remoteActorID))
34 | }
35 |
--------------------------------------------------------------------------------
/outbox/actor-send-like.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/benpate/hannibal"
7 | "github.com/benpate/hannibal/streams"
8 | "github.com/benpate/hannibal/vocab"
9 | "github.com/benpate/rosetta/mapof"
10 | "github.com/rs/zerolog/log"
11 | )
12 |
13 | // SendLike sends an "Like" message to the recipient
14 | // activity: The activity that is being announced
15 | func (actor *Actor) SendLike(likeID string, object streams.Document) {
16 |
17 | if canDebug() {
18 | log.Debug().Msg("outbox.Actor.SendLike: " + likeID)
19 | }
20 |
21 | message := mapof.Any{
22 | vocab.AtContext: vocab.ContextTypeActivityStreams,
23 | vocab.PropertyType: vocab.ActivityTypeLike,
24 | vocab.PropertyID: likeID,
25 | vocab.PropertyActor: actor.actorID,
26 | vocab.PropertyObject: object.Map(),
27 | vocab.PropertyPublished: hannibal.TimeFormat(time.Now()),
28 | }
29 |
30 | actor.Send(message, actor.followers, object.RangeAddressees())
31 | }
32 |
--------------------------------------------------------------------------------
/outbox/actor-send-undo.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/benpate/hannibal"
7 | "github.com/benpate/hannibal/streams"
8 | "github.com/benpate/hannibal/vocab"
9 | "github.com/benpate/rosetta/mapof"
10 | "github.com/rs/zerolog/log"
11 | )
12 |
13 | // SendUndo sends an "Undo" message to the recipient
14 | // actor: The Actor that is sending the request
15 | // activity: The activity that has been undone
16 | // recipient: The ActivityStream profile of the message recipient
17 | func (actor *Actor) SendUndo(activity streams.Document) {
18 |
19 | if canDebug() {
20 | log.Debug().Msg("outbox.Actor.SendUndo: " + activity.ID())
21 | }
22 |
23 | // Build the ActivityPub Message
24 | message := mapof.Any{
25 | vocab.AtContext: vocab.ContextTypeActivityStreams,
26 | vocab.PropertyType: vocab.ActivityTypeUndo,
27 | vocab.PropertyActor: actor.ActorID,
28 | vocab.PropertyObject: activity.Map(),
29 | vocab.PropertyPublished: hannibal.TimeFormat(time.Now()),
30 | }
31 |
32 | actor.Send(message, activity.RangeAddressees())
33 | }
34 |
--------------------------------------------------------------------------------
/outbox/actor-send-update.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/benpate/hannibal"
7 | "github.com/benpate/hannibal/streams"
8 | "github.com/benpate/hannibal/vocab"
9 | "github.com/benpate/rosetta/mapof"
10 | "github.com/rs/zerolog/log"
11 | )
12 |
13 | // SendUpdate sends an "Update" message to the recipient
14 | // actor: The Actor that is sending the request
15 | // activity: The activity that has been updated
16 | // recipient: The ActivityStream profile of the message recipient
17 | func (actor *Actor) SendUpdate(document streams.Document) {
18 |
19 | if canDebug() {
20 | log.Debug().Msg("outbox.Actor.SendUpdate: " + document.ID())
21 | }
22 |
23 | message := mapof.Any{
24 | vocab.AtContext: vocab.ContextTypeActivityStreams,
25 | vocab.PropertyType: vocab.ActivityTypeUpdate,
26 | vocab.PropertyActor: actor.actorID,
27 | vocab.PropertyObject: document.Map(),
28 | vocab.PropertyPublished: hannibal.TimeFormat(time.Now()),
29 | }
30 |
31 | actor.Send(
32 | message,
33 | document.RangeAddressees(),
34 | document.RangeInReplyTo(),
35 | actor.followers,
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/outbox/actor-send.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "iter"
5 | "net/url"
6 |
7 | "github.com/benpate/derp"
8 | "github.com/benpate/hannibal/streams"
9 | "github.com/benpate/hannibal/vocab"
10 | "github.com/benpate/remote"
11 | "github.com/benpate/remote/options"
12 | "github.com/benpate/rosetta/mapof"
13 | )
14 |
15 | /******************************************
16 | * Sending Messages
17 | ******************************************/
18 |
19 | // Send pushes a message onto the outbound queue, sending it to
20 | // all recipients in the iterator.
21 | // https://www.w3.org/TR/activitypub/#delivery
22 | func (actor *Actor) Send(message mapof.Any, recipients ...iter.Seq[string]) {
23 |
24 | const location = "hannibal.outbox.actor.Send"
25 |
26 | // Send the message to each recipient
27 | for _, iterator := range recipients {
28 |
29 | for recipientID := range iterator {
30 |
31 | // Don't send to empty recipients
32 | if recipientID == "" {
33 | continue
34 | }
35 |
36 | // Don't send to the magic public recipient
37 | if recipientID == vocab.NamespaceActivityStreamsPublic {
38 | continue
39 | }
40 |
41 | // Don't send messages to myself
42 | if recipientID == actor.actorID {
43 | continue
44 | }
45 |
46 | if err := actor.SendOne(recipientID, message); err != nil {
47 | derp.Report(derp.Wrap(err, location, "Error sending message", recipientID))
48 | }
49 | }
50 | }
51 | }
52 |
53 | // SendOne sends a single message to a single recipient
54 | func (actor *Actor) SendOne(recipientID string, message mapof.Any) error {
55 |
56 | const location = "hannibal.outbox.actor.SendOne"
57 |
58 | // Use the recipientID to look up their inbox URL
59 | recipient := streams.NewDocument(recipientID, streams.WithClient(actor.getClient()))
60 | recipient, err := recipient.Load()
61 |
62 | if err != nil {
63 | return derp.Wrap(err, location, "Error loading recipient", recipientID)
64 | }
65 |
66 | inboxURL := recipient.Inbox().ID()
67 |
68 | // RULE: InboxURL must be a valid URL
69 | inbox, err := url.Parse(inboxURL)
70 |
71 | if err != nil {
72 | return derp.Wrap(err, location, "Invalid Inbox URL", inboxURL)
73 | }
74 |
75 | // Prepare a transaction to send to target Actor's inbox
76 | transaction := remote.Post(inbox.String()).
77 | Accept(vocab.ContentTypeActivityPub).
78 | ContentType(vocab.ContentTypeActivityPub).
79 | With(SignRequest(*actor)).
80 | JSON(message)
81 |
82 | if canDebug() {
83 | transaction.With(options.Debug())
84 | }
85 |
86 | // Send the transaction
87 | if err := transaction.Send(); err != nil {
88 | return derp.Wrap(err, location, "Error sending ActivityPub request", inboxURL)
89 | }
90 |
91 | return nil
92 | }
93 |
--------------------------------------------------------------------------------
/outbox/actorOption.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "iter"
5 |
6 | "github.com/benpate/hannibal/streams"
7 | )
8 |
9 | // ActorOption is a function signature that modifies optional settings for an Actor
10 | type ActorOption func(*Actor)
11 |
12 | // WithPublicKey is an ActorOption that sets the public key for an Actor
13 | func WithPublicKey(publicKeyID string) ActorOption {
14 | return func(a *Actor) {
15 | a.publicKeyID = publicKeyID
16 | }
17 | }
18 |
19 | // WithCliient is an ActorOption that sets the hanibal Client for an Actor
20 | func WithClient(client streams.Client) ActorOption {
21 | return func(a *Actor) {
22 | a.client = client
23 | }
24 | }
25 |
26 | func WithFollowers(followers iter.Seq[string]) ActorOption {
27 | return func(a *Actor) {
28 | a.followers = followers
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/outbox/remoteOption.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/benpate/derp"
7 | "github.com/benpate/hannibal/sigs"
8 | "github.com/benpate/remote"
9 | )
10 |
11 | // SignRequest is a middleware for the remote package that adds an HTTP Signature to a request.
12 | func SignRequest(actor Actor) remote.Option {
13 |
14 | return remote.Option{
15 |
16 | ModifyRequest: func(txn *remote.Transaction, request *http.Request) *http.Response {
17 |
18 | // Add a "Digest" header to the request and sign the outgoing request.
19 | if err := sigs.Sign(request, actor.publicKeyID, actor.privateKey); err != nil {
20 | derp.Report(derp.Wrap(err, "activitypub.RequestSignature", "Error signing HTTP request. This is likely because of a problem with the actor's private key."))
21 | }
22 |
23 | // If exists, write the Digest back into the transaction (for serialization, et al)
24 | if digest := request.Header.Get("Digest"); digest != "" {
25 | txn.Header("Digest", digest)
26 | }
27 |
28 | // If exists, write the Signature back into the transaction (for serialization, et al)
29 | if signature := request.Header.Get("Signature"); signature != "" {
30 | txn.Header("Signature", signature)
31 | }
32 |
33 | // Oh, yeah...
34 | return nil
35 | },
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/outbox/utils.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "iter"
5 |
6 | "github.com/rs/zerolog"
7 | )
8 |
9 | // canDebug returns TRUE if zerolog is configured to allow Debug logs
10 | func canDebug() bool {
11 | return canLog(zerolog.DebugLevel)
12 | }
13 |
14 | // canTrace returns TRUE if zerolog is configured to allow Trace logs
15 | // func canTrace() bool {
16 | // return canLog(zerolog.TraceLevel)
17 | // }
18 |
19 | // canLog is a silly zerolog helper that returns TRUE
20 | // if the provided log level would be allowed
21 | // (based on the global log level).
22 | // This makes it easier to execute expensive code conditionally,
23 | // for instance: marshalling a JSON object for logging.
24 | func canLog(level zerolog.Level) bool {
25 | return zerolog.GlobalLevel() <= level
26 | }
27 |
28 | func makeIterator[T any](values ...T) iter.Seq[T] {
29 | return func(yield func(T) bool) {
30 | for _, value := range values {
31 | if !yield(value) {
32 | return
33 | }
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/property/README.md:
--------------------------------------------------------------------------------
1 | ## Hannibal / unit
2 |
3 | This package wraps a number of common data values with conversions used in ActivityStreams. This is important because JSON-LD allows any piece of data to be represented in multiple formats. This wrapper allows safe access to indidividual values even when there are nil values slices, or maps of values present.
4 |
5 | ### Usage
6 | ```go
7 | value := property.NewValue("http://foo.com")
8 |
9 | foo.Raw() // returns "foo"
10 |
11 | // Traverse Arrays
12 | foo.Len() // returns 1
13 | foo.Head().Raw() // returns "http://foo.com"
14 | foo.Tail().Raw() // returns a nil value
15 |
16 | // Represent maps
17 | foo.Map() // returns a map with "id"="http://foo.com"
18 | foo.Set("name", "Foo") // converts foo to a map and sets a new property
19 | foo.Get("name").Raw() // returns "Foo"
20 | ```
21 |
22 | ## Interfaces
23 | Everything implements the `property.Value` interface, which provides several low-level manipulations for reading, writing, and transforming values. You can implement this interface in other packages to use other custom types with the rest of the hannibal library.
24 |
--------------------------------------------------------------------------------
/property/bool.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import "github.com/benpate/rosetta/convert"
4 |
5 | type Bool bool
6 |
7 | func (value Bool) IsBool() bool {
8 | return true
9 | }
10 |
11 | func (value Bool) Bool() bool {
12 | return bool(value)
13 | }
14 |
15 | // Get returns a value of the given property
16 | func (value Bool) Get(_ string) Value {
17 | return Nil{}
18 | }
19 |
20 | // Set returns the value with the given property set
21 | func (value Bool) Set(property string, propertyValue any) Value {
22 | return Map{
23 | property: propertyValue,
24 | }
25 | }
26 |
27 | // Head returns the first value in a slice
28 | func (value Bool) Head() Value {
29 | return value
30 | }
31 |
32 | // Tail returns all values in a slice except the first
33 | func (value Bool) Tail() Value {
34 | return Nil{}
35 | }
36 |
37 | // Len returns the number of elements in the value
38 | func (value Bool) Len() int {
39 | return 1
40 | }
41 |
42 | // IsNil returns TRUE if this boolean value is nil
43 | func (value Bool) IsNil() bool {
44 | return !bool(value)
45 | }
46 |
47 | func (value Bool) String() string {
48 | return convert.String(value)
49 | }
50 |
51 | func (value Bool) Map() map[string]any {
52 | return make(map[string]any)
53 | }
54 |
55 | func (value Bool) Raw() any {
56 | return bool(value)
57 | }
58 |
59 | func (value Bool) Clone() Value {
60 | return value
61 | }
62 |
--------------------------------------------------------------------------------
/property/float.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import "github.com/benpate/rosetta/convert"
4 |
5 | type Float float64
6 |
7 | func (value Float) IsFloat() bool {
8 | return true
9 | }
10 |
11 | func (value Float) Float() float64 {
12 | return float64(value)
13 | }
14 |
15 | // Get returns a value of the given property
16 | func (value Float) Get(_ string) Value {
17 | return Nil{}
18 | }
19 |
20 | // Set returns the value with the given property set
21 | func (value Float) Set(propertyName string, propertyValue any) Value {
22 | return Map{
23 | propertyName: propertyValue,
24 | }
25 | }
26 |
27 | // Head returns the first value in a slice
28 | func (value Float) Head() Value {
29 | return value
30 | }
31 |
32 | // Tail returns all values in a slice except the first
33 | func (value Float) Tail() Value {
34 | return Nil{}
35 | }
36 |
37 | // Len returns the number of elements in the value
38 | func (value Float) Len() int {
39 | return 1
40 | }
41 |
42 | func (value Float) IsNil() bool {
43 | return value == 0
44 | }
45 |
46 | func (value Float) String() string {
47 | return convert.String(value)
48 | }
49 |
50 | func (value Float) Map() map[string]any {
51 | return make(map[string]any)
52 | }
53 |
54 | func (value Float) Raw() any {
55 | return float64(value)
56 | }
57 |
58 | func (value Float) Clone() Value {
59 | return value
60 | }
61 |
--------------------------------------------------------------------------------
/property/int.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import "github.com/benpate/rosetta/convert"
4 |
5 | type Int int
6 |
7 | func (value Int) IsInt() bool {
8 | return true
9 | }
10 |
11 | func (value Int) IsInt64() bool {
12 | return true
13 | }
14 |
15 | func (value Int) Int() int {
16 | return int(value)
17 | }
18 |
19 | func (value Int) Int64() int64 {
20 | return int64(value)
21 | }
22 |
23 | // Get returns a value of the given property
24 | func (value Int) Get(_ string) Value {
25 | return Nil{}
26 | }
27 |
28 | // Set returns the value with the given property set
29 | func (value Int) Set(propertyName string, propertyValue any) Value {
30 | return Map{
31 | propertyName: propertyValue,
32 | }
33 | }
34 |
35 | // Head returns the first value in a slice
36 | func (value Int) Head() Value {
37 | return value
38 | }
39 |
40 | // Tail returns all values in a slice except the first
41 | func (value Int) Tail() Value {
42 | return Nil{}
43 | }
44 |
45 | // Len returns the number of elements in the value
46 | func (value Int) Len() int {
47 | return 1
48 | }
49 |
50 | func (value Int) IsNil() bool {
51 | return value == 0
52 | }
53 |
54 | func (value Int) String() string {
55 | return convert.String(value)
56 | }
57 |
58 | func (value Int) Map() map[string]any {
59 | return make(map[string]any)
60 | }
61 |
62 | func (value Int) Raw() any {
63 | return int(value)
64 | }
65 |
66 | func (value Int) Clone() Value {
67 | return value
68 | }
69 |
--------------------------------------------------------------------------------
/property/int64.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import "github.com/benpate/rosetta/convert"
4 |
5 | type Int64 int64
6 |
7 | func (value Int64) IsInt() bool {
8 | return true
9 | }
10 |
11 | func (value Int64) IsInt64() bool {
12 | return true
13 | }
14 |
15 | func (value Int64) Int() int {
16 | return int(value)
17 | }
18 |
19 | func (value Int64) Int64() int64 {
20 | return int64(value)
21 | }
22 |
23 | // Get returns a value of the given property
24 | func (value Int64) Get(_ string) Value {
25 | return Nil{}
26 | }
27 |
28 | // Set returns the value with the given property set
29 | func (value Int64) Set(propertyName string, propertyValue any) Value {
30 | return Map{
31 | propertyName: propertyValue,
32 | }
33 | }
34 |
35 | // Head returns the first value in a slice
36 | func (value Int64) Head() Value {
37 | return value
38 | }
39 |
40 | // Tail returns all values in a slice except the first
41 | func (value Int64) Tail() Value {
42 | return Nil{}
43 | }
44 |
45 | // Len returns the number of elements in the value
46 | func (value Int64) Len() int {
47 | return 1
48 | }
49 |
50 | func (value Int64) IsNil() bool {
51 | return value == 0
52 | }
53 |
54 | func (value Int64) String() string {
55 | return convert.String(value)
56 | }
57 |
58 | func (value Int64) Map() map[string]any {
59 | return make(map[string]any)
60 | }
61 |
62 | func (value Int64) Raw() any {
63 | return int64(value)
64 | }
65 |
66 | func (value Int64) Clone() Value {
67 | return value
68 | }
69 |
--------------------------------------------------------------------------------
/property/interfaces.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import "time"
4 |
5 | /******************************************
6 | * Introspection Interfaces
7 | ******************************************/
8 |
9 | type IsBooler interface {
10 | IsBool() bool
11 | Bool() bool
12 | }
13 |
14 | type IsInter interface {
15 | IsInt() bool
16 | Int() int
17 | }
18 |
19 | type IsInt64er interface {
20 | IsInt64() bool
21 | Int64() int64
22 | }
23 |
24 | type IsFloater interface {
25 | IsFloat() bool
26 | Float() float64
27 | }
28 |
29 | type IsMapper interface {
30 | IsMap() bool
31 | Map() map[string]any
32 | MapKeys() []string
33 | }
34 |
35 | type IsSlicer interface {
36 | IsSlice() bool
37 | Slice() []any
38 | }
39 |
40 | type IsStringer interface {
41 | IsString() bool
42 | String() string
43 | }
44 |
45 | type IsTimeer interface {
46 | IsTime() bool
47 | Time() time.Time
48 | }
49 |
50 | /******************************************
51 | * Getter Interfaces
52 | ******************************************/
53 |
54 | // BoolGetter is an optional interface that should be implemented
55 | // by any property.Value that contains a bool
56 | type BoolGetter interface {
57 | // Bool returns a value typed as a bool
58 | Bool() bool
59 | }
60 |
61 | // IntGetter is an optional interface that should be implemented
62 | // by any property.Value that contains an int
63 | type IntGetter interface {
64 | // Int returns the value typed as an int
65 | Int() int
66 | }
67 |
68 | // Int64Getter is an optional interface that should be implemented
69 | // by any property.Value that contains an int64
70 | type Int64Getter interface {
71 | // Int64 returns the value typed as an int64
72 | Int64() int64
73 | }
74 |
75 | // FloatGetter is an optional interface that should be implemented
76 | // by any property.Value that contains a float64
77 | type FloatGetter interface {
78 | // Float returns the value typed as a float64
79 | Float() float64
80 | }
81 |
82 | // MapGetter is an optional interface that should be implemented
83 | // by any property.Value that contains a map[string]any
84 | type MapGetter interface {
85 | // Map returns the value typed as a map[string]any
86 | Map() map[string]any
87 | }
88 |
89 | // SliceGetter is an optional interface that should be implemented
90 | // by any property.Value that contains a []any
91 | type SliceGetter interface {
92 | // Slice returns the value typed as a []any
93 | Slice() []any
94 | }
95 |
96 | // StringGetter is an optional interface that should be implemented
97 | // by any property.Value that contains a string
98 | type StringGetter interface {
99 | // String returns the value typed as a string
100 | String() string
101 | }
102 |
103 | // TimeGetter is an optional interface that should be implemented
104 | // by any property.Value that contains a time.Time
105 | type TimeGetter interface {
106 | // Time returns the value typed as a time.Time
107 | Time() time.Time
108 | }
109 |
--------------------------------------------------------------------------------
/property/map.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import (
4 | "github.com/benpate/hannibal/vocab"
5 | "github.com/benpate/rosetta/convert"
6 | )
7 |
8 | type Map map[string]any
9 |
10 | // Get returns a value of the given property
11 | func (value Map) Get(name string) Value {
12 |
13 | if property, ok := value[name]; ok {
14 | return NewValue(property)
15 | }
16 |
17 | return Nil{}
18 | }
19 |
20 | // Set returns the value with the given property set
21 | func (value Map) Set(name string, newValue any) Value {
22 | value[name] = newValue
23 | return value
24 | }
25 |
26 | // Head returns the first value in a slice
27 | func (value Map) Head() Value {
28 | return value
29 | }
30 |
31 | // Tail returns all values in a slice except the first
32 | func (value Map) Tail() Value {
33 | return Nil{}
34 | }
35 |
36 | // Len returns the number of elements in the value
37 | func (value Map) Len() int {
38 | return 1
39 | }
40 |
41 | func (value Map) IsNil() bool {
42 | return len(value) == 0
43 | }
44 |
45 | func (value Map) String() string {
46 | return convert.String(value[vocab.PropertyID])
47 | }
48 |
49 | func (value Map) Raw() any {
50 | return map[string]any(value)
51 | }
52 |
53 | func (value Map) Clone() Value {
54 | result := make(map[string]any)
55 |
56 | for key, value := range value {
57 | result[key] = value
58 | }
59 |
60 | return Map(value)
61 | }
62 |
63 | /******************************************
64 | * IsMapper Interface
65 | ******************************************/
66 |
67 | func (value Map) IsMap() bool {
68 | return true
69 | }
70 |
71 | func (value Map) Map() map[string]any {
72 | return value
73 | }
74 |
75 | func (value Map) MapKeys() []string {
76 | result := make([]string, 0, len(value))
77 | for key := range value {
78 | result = append(result, key)
79 | }
80 | return result
81 | }
82 |
--------------------------------------------------------------------------------
/property/nil.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | type Nil struct{}
4 |
5 | // Get returns a value of the given property
6 | func (value Nil) Get(string) Value {
7 | return Nil{}
8 | }
9 |
10 | // Set returns the value with the given property set
11 | func (value Nil) Set(string, any) Value {
12 | return Nil{}
13 | }
14 |
15 | // Head returns the first value in a slice
16 | func (value Nil) Head() Value {
17 | return Nil{}
18 | }
19 |
20 | // Tail returns all values in a slice except the first
21 | func (value Nil) Tail() Value {
22 | return Nil{}
23 | }
24 |
25 | // Len returns the number of elements in the value
26 | func (value Nil) Len() int {
27 | return 0
28 | }
29 |
30 | func (value Nil) IsNil() bool {
31 | return true
32 | }
33 |
34 | func (value Nil) String() string {
35 | return ""
36 | }
37 |
38 | func (value Nil) Map() map[string]any {
39 | return make(map[string]any)
40 | }
41 |
42 | func (value Nil) Raw() any {
43 | return nil
44 | }
45 |
46 | func (value Nil) Clone() Value {
47 | return Nil{}
48 | }
49 |
--------------------------------------------------------------------------------
/property/slice.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | type Slice []any
4 |
5 | func (value Slice) IsSlice() bool {
6 | return true
7 | }
8 |
9 | func (value Slice) Slice() []any {
10 | return []any(value)
11 | }
12 |
13 | // Get returns a value of the given property
14 | func (value Slice) Get(name string) Value {
15 | return value.Head().Get(name)
16 | }
17 |
18 | // Set returns the value with the given property set
19 | func (value Slice) Set(name string, newValue any) Value {
20 |
21 | if len(value) == 0 {
22 | first := Nil{}.Set(name, newValue)
23 | return Slice([]any{first})
24 | }
25 |
26 | first := value.Head().Set(name, newValue)
27 | return Slice(append([]any{first.Raw()}, value[1:]...))
28 | }
29 |
30 | // Head returns the first value in a slice
31 | func (value Slice) Head() Value {
32 | if len(value) == 0 {
33 | return Nil{}
34 | }
35 |
36 | return NewValue(value[0])
37 | }
38 |
39 | // Tail returns all values in a slice except the first
40 | func (value Slice) Tail() Value {
41 | if len(value) == 0 {
42 | return Nil{}
43 | }
44 |
45 | return Slice(value[1:])
46 | }
47 |
48 | // Len returns the number of elements in the value
49 | func (value Slice) Len() int {
50 | return len(value)
51 | }
52 |
53 | // IsNil returns true if the value is nil
54 | func (value Slice) IsNil() bool {
55 | return len(value) == 0
56 | }
57 |
58 | // String returns a string representation of the value
59 | func (value Slice) String() string {
60 | return "" // value.Head().String()
61 | }
62 |
63 | // Map returns the value as a map
64 | func (value Slice) Map() map[string]any {
65 | return value.Head().Map()
66 | }
67 |
68 | // Raw returns the original wrapped value
69 | func (value Slice) Raw() any {
70 | return []any(value)
71 | }
72 |
73 | // Clone returns a deep copy of the value
74 | func (value Slice) Clone() Value {
75 | result := make([]any, len(value))
76 | copy(result, value)
77 |
78 | return Slice(value)
79 | }
80 |
--------------------------------------------------------------------------------
/property/string.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import "github.com/benpate/hannibal/vocab"
4 |
5 | type String string
6 |
7 | func (value String) IsString() bool {
8 | return true
9 | }
10 |
11 | // Get returns a value of the given property
12 | func (value String) Get(propertyName string) Value {
13 |
14 | if propertyName == vocab.PropertyID {
15 | return value
16 | }
17 |
18 | return Nil{}
19 | }
20 |
21 | // Set returns the value with the given property set
22 | func (value String) Set(propertyName string, propertyValue any) Value {
23 | result := Map{
24 | vocab.PropertyID: value,
25 | }
26 |
27 | return result.Set(propertyName, propertyValue)
28 | }
29 |
30 | // Head returns the first value in a slice
31 | func (value String) Head() Value {
32 | return value
33 | }
34 |
35 | // Tail returns all values in a slice except the first
36 | func (value String) Tail() Value {
37 | return Nil{}
38 | }
39 |
40 | // Len returns the number of elements in the value
41 | func (value String) Len() int {
42 | return 1
43 | }
44 |
45 | // IsNil returns TRUE if the value is nil
46 | func (value String) IsNil() bool {
47 | return value == ""
48 | }
49 |
50 | // String returns a string representation of the value
51 | func (value String) String() string {
52 | return string(value)
53 | }
54 |
55 | // Map returns the value as a map
56 | func (value String) Map() map[string]any {
57 | return map[string]any{
58 | vocab.PropertyID: value,
59 | }
60 | }
61 |
62 | // Raw returns the raw, original value
63 | func (value String) Raw() any {
64 | return string(value)
65 | }
66 |
67 | // Clone returns a deep copy of the value
68 | func (value String) Clone() Value {
69 | return value
70 | }
71 |
--------------------------------------------------------------------------------
/property/string_test.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | func TestString(t *testing.T) {
10 |
11 | value := NewValue("Hello, World!")
12 |
13 | require.Equal(t, "Hello, World!", value.Raw())
14 | require.Equal(t, "Hello, World!", value.Head().Raw())
15 | require.Nil(t, value.Tail().Raw())
16 |
17 | require.True(t, IsString(value))
18 | }
19 |
--------------------------------------------------------------------------------
/property/time.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/benpate/hannibal"
7 | )
8 |
9 | type Time time.Time
10 |
11 | func (value Time) IsTime() bool {
12 | return true
13 | }
14 |
15 | func (value Time) Time() time.Time {
16 | return time.Time(value)
17 | }
18 |
19 | // Get returns a value of the given property
20 | func (value Time) Get(_ string) Value {
21 | return Nil{}
22 | }
23 |
24 | // Set returns the value with the given property set
25 | func (value Time) Set(propertyName string, propertyValue any) Value {
26 | return Map{
27 | propertyName: propertyValue,
28 | }
29 | }
30 |
31 | // Head returns the first value in a slice
32 | func (value Time) Head() Value {
33 | return value
34 | }
35 |
36 | // Tail returns all values in a slice except the first
37 | func (value Time) Tail() Value {
38 | return Nil{}
39 | }
40 |
41 | // Len returns the number of elements in the value
42 | func (value Time) Len() int {
43 | return 1
44 | }
45 |
46 | // IsNil returns TRUE if the value is nil
47 | func (value Time) IsNil() bool {
48 | return time.Time(value).IsZero()
49 | }
50 |
51 | // String returs the string representation of the value
52 | func (value Time) String() string {
53 | return hannibal.TimeFormat(time.Time(value))
54 | }
55 |
56 | // Map returns the value as a map[string]any
57 | func (value Time) Map() map[string]any {
58 | return make(map[string]any)
59 | }
60 |
61 | // Raw returns the raw, original value
62 | func (value Time) Raw() any {
63 | return time.Time(value)
64 | }
65 |
66 | // Clone returns a deep copy of the value
67 | func (value Time) Clone() Value {
68 | return value
69 | }
70 |
--------------------------------------------------------------------------------
/property/value.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/benpate/rosetta/convert"
7 | "github.com/benpate/rosetta/mapof"
8 | "github.com/benpate/rosetta/sliceof"
9 | )
10 |
11 | // Value is a wrapper for any kind of value that might be used in a streams.Document
12 | type Value interface {
13 |
14 | // Get returns a value of the given property
15 | Get(string) Value
16 |
17 | // Set returns the value with the given property set
18 | Set(string, any) Value
19 |
20 | // Head returns the first value in a slices, or the value itself if it is not a slice
21 | Head() Value
22 |
23 | // Tail returns all values in a slice except the first
24 | Tail() Value
25 |
26 | // Len returns the number of elements in the value
27 | Len() int
28 |
29 | // IsNil returns TRUE if the value is empty
30 | IsNil() bool
31 |
32 | // Map returns the map representation of this value
33 | Map() map[string]any
34 |
35 | // Raw returns the raw, unwrapped value being stored
36 | Raw() any
37 |
38 | // Clone returns a deep copy of a value
39 | Clone() Value
40 | }
41 |
42 | func NewValue(value any) Value {
43 |
44 | // NILCHECK: If value is nil, then exit here.
45 | if value == nil {
46 | return Nil{}
47 | }
48 |
49 | switch typed := value.(type) {
50 |
51 | // We already have a value, so return it
52 | case Value:
53 | return typed
54 |
55 | // Raw values
56 | case bool:
57 | return Bool(typed)
58 |
59 | case float32:
60 | return Float(typed)
61 |
62 | case float64:
63 | return Float(typed)
64 |
65 | case int:
66 | return Int(typed)
67 |
68 | case int64:
69 | return Int64(typed)
70 |
71 | case map[string]any:
72 | return Map(typed)
73 |
74 | case mapof.Any:
75 | return Map(typed)
76 |
77 | case []any:
78 | return Slice(typed)
79 |
80 | case sliceof.Any:
81 | return Slice(typed)
82 |
83 | case string:
84 | return String(typed)
85 |
86 | case time.Time:
87 | return Time(typed)
88 |
89 | // Conversion Interfaces
90 | case BoolGetter:
91 | return Bool(typed.Bool())
92 |
93 | case FloatGetter:
94 | return Float(typed.Float())
95 |
96 | case IntGetter:
97 | return Int(typed.Int())
98 |
99 | case Int64Getter:
100 | return Int64(typed.Int64())
101 |
102 | case MapGetter:
103 | return Map(typed.Map())
104 |
105 | case SliceGetter:
106 | return Slice(typed.Slice())
107 |
108 | case StringGetter:
109 | return String(typed.String())
110 |
111 | case TimeGetter:
112 | return Time(typed.Time())
113 | }
114 |
115 | // More checks for wayward values (like primitive.A)
116 |
117 | if convert.IsMap(value) {
118 | return Map(convert.MapOfAny(value))
119 | }
120 |
121 | if convert.IsSlice(value) {
122 | return Slice(convert.SliceOfAny(value))
123 | }
124 |
125 | return Nil{}
126 | }
127 |
128 | /******************************************
129 | * Introspection Functions
130 | ******************************************/
131 |
132 | // IsBool returns TRUE if the value represents a bool
133 | func IsBool(value any) bool {
134 | if is, ok := value.(IsBooler); ok {
135 | return is.IsBool()
136 | }
137 | return false
138 | }
139 |
140 | // IsInt returns TRUE if the value represents a float
141 | func IsFloat(value any) bool {
142 | if is, ok := value.(IsFloater); ok {
143 | return is.IsFloat()
144 | }
145 | return false
146 | }
147 |
148 | // IsInt returns TRUE if the value represents an int
149 | func IsInt(value any) bool {
150 | if is, ok := value.(IsInter); ok {
151 | return is.IsInt()
152 | }
153 | return false
154 | }
155 |
156 | // IsInt64 returns TRUE if the value represents an int64
157 | func IsInt64(value any) bool {
158 | if is, ok := value.(IsInt64er); ok {
159 | return is.IsInt64()
160 | }
161 | return false
162 | }
163 |
164 | // IsMap returns TRUE if the value represents a map
165 | func IsMap(value any) bool {
166 | if is, ok := value.(IsMapper); ok {
167 | return is.IsMap()
168 | }
169 | return false
170 | }
171 |
172 | // IsSlice returns TRUE if the value represents a slice
173 | func IsSlice(value any) bool {
174 | if is, ok := value.(IsSlicer); ok {
175 | return is.IsSlice()
176 | }
177 | return false
178 | }
179 |
180 | // IsString returns TRUE if the value represents a string
181 | func IsString(value any) bool {
182 | if is, ok := value.(IsStringer); ok {
183 | return is.IsString()
184 | }
185 | return false
186 | }
187 |
188 | // IsTime returns TRUE if the value represents a time.Time
189 | func IsTime(value any) bool {
190 | if is, ok := value.(IsTimeer); ok {
191 | return is.IsTime()
192 | }
193 | return false
194 | }
195 |
--------------------------------------------------------------------------------
/sigs/certificates.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "crypto"
5 | "crypto/rsa"
6 | "crypto/x509"
7 | "encoding/pem"
8 |
9 | "github.com/benpate/derp"
10 | )
11 |
12 | // EncodePrivatePEM converts a private key into a PEM string
13 | func EncodePrivatePEM(privateKey *rsa.PrivateKey) string {
14 |
15 | // Get ASN.1 DER format
16 | privDER := x509.MarshalPKCS1PrivateKey(privateKey)
17 |
18 | // pem.Block
19 | privBlock := pem.Block{
20 | Type: "RSA PRIVATE KEY",
21 | Headers: nil,
22 | Bytes: privDER,
23 | }
24 |
25 | // Private key in PEM format
26 | privatePEM := pem.EncodeToMemory(&privBlock)
27 |
28 | return string(privatePEM)
29 | }
30 |
31 | // EncodePublicPEM converts a public key into a PEM string
32 | func EncodePublicPEM(privateKey *rsa.PrivateKey) string {
33 |
34 | // Get ASN.1 DER format
35 | publicDER := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey)
36 |
37 | // pem.Block
38 | publicBlock := pem.Block{
39 | Type: "RSA PUBLIC KEY",
40 | Headers: nil,
41 | Bytes: publicDER,
42 | }
43 |
44 | // Private key in PEM format
45 | publicPEM := pem.EncodeToMemory(&publicBlock)
46 |
47 | return string(publicPEM)
48 | }
49 |
50 | // DecodePrivatePEM converts a PEM string into a private key
51 | func DecodePrivatePEM(pemString string) (crypto.PrivateKey, error) {
52 |
53 | const location = "hannibal.sigs.DecodePrivatePEM"
54 |
55 | block, _ := pem.Decode([]byte(pemString))
56 |
57 | if block == nil {
58 | return nil, derp.InternalError(location, "Block is nil", pemString)
59 | }
60 |
61 | switch block.Type {
62 |
63 | case "RSA PRIVATE KEY":
64 | result, err := x509.ParsePKCS1PrivateKey(block.Bytes)
65 |
66 | if err != nil {
67 | return nil, derp.Wrap(err, location, "Error parsing PKCS1 private key")
68 | }
69 |
70 | return result, nil
71 |
72 | case "PRIVATE KEY":
73 | result, err := x509.ParsePKCS8PrivateKey(block.Bytes)
74 |
75 | if err != nil {
76 | return nil, derp.Wrap(err, location, "Error parsing PKCS8 private key")
77 | }
78 |
79 | return result, nil
80 |
81 | default:
82 | return nil, derp.InternalError(location, "Invalid block type", block.Type)
83 | }
84 | }
85 |
86 | // DecodePublicPEM converts a PEM string into a public key
87 | func DecodePublicPEM(pemString string) (crypto.PublicKey, error) {
88 |
89 | const location = "hannibal.sigs.DecodePublicPEM"
90 | block, _ := pem.Decode([]byte(pemString))
91 |
92 | if block == nil {
93 | return nil, derp.InternalError(location, "Block is nil", pemString)
94 | }
95 |
96 | switch block.Type {
97 |
98 | case "RSA PUBLIC KEY":
99 | return x509.ParsePKCS1PublicKey(block.Bytes)
100 |
101 | case "PUBLIC KEY":
102 | return x509.ParsePKIXPublicKey(block.Bytes)
103 |
104 | default:
105 | return nil, derp.InternalError(location, "Invalid block type", block.Type)
106 | }
107 |
108 | }
109 |
--------------------------------------------------------------------------------
/sigs/certificates_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "crypto/rand"
5 | "crypto/rsa"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func TestEncodeDecodePEM(t *testing.T) {
12 |
13 | key, err := rsa.GenerateKey(rand.Reader, 2048)
14 |
15 | require.Nil(t, err)
16 |
17 | publicPEM := EncodePublicPEM(key)
18 | parsedKey, err := DecodePublicPEM(publicPEM)
19 |
20 | require.Nil(t, err)
21 | require.Equal(t, key.N, parsedKey.(*rsa.PublicKey).N)
22 | require.Equal(t, key.E, parsedKey.(*rsa.PublicKey).E)
23 | }
24 |
25 | // This is a key generated by Emissary
26 | func TestParseRSAKey(t *testing.T) {
27 |
28 | // hs2019 certs
29 | cert := "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAlifIuRIJII6Z1FblKF0XD17nTCYP+AdX+9KXJ2/WOfxs2/CTVFGa\nq0ovIdoDK2jydatRciYmwYYS+xrixzvPdUqN3Kcpev1MwUqQuThVy3QiJAI0sJ0L\nJzruzZKXt8q3YbuWcMiNwfOv0/+VpsZHa7yltxFKHiULIBz/dF/0t3ulwGeUpVGA\nQrH0uWfmPs8hPkaIs8IIgEFGyUjPHejK+jZaqUSxbBlpzvnaEgKEJVXYawHCUipF\nQp5N1zdXi+FNNuERqixWHdmyguNWTLdwmH9bq1nFe6pLDG4eqXnEGFDXXZ90SQhe\nsfEDVzm0Yzcv7wkJFm80aPDsa7Y+IARddwIDAQAB\n-----END RSA PUBLIC KEY-----\n"
30 | key, err := DecodePublicPEM(cert)
31 |
32 | require.Nil(t, err)
33 | require.NotNil(t, key)
34 | }
35 |
36 | // This is a public key from a Mastodon instance
37 | func TestParseRSAKey2(t *testing.T) {
38 |
39 | cert := "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApIC6kVMMkk0cc4GCAQ+/\n5XAKz8KhSbSpsWFC1jVRH+AXDAH3O6Pw+r5xNUu+OEGUPnLXycxuPZapxTQPGw/w\nPUMk2QvEhi9WEECrdPG8y2dEZMcNvGhtOtyhIWHGBLfANcpeueW3Z8WBR2Eak5+Y\nkuy+oiS3CZ13JAxbY6AfNc6L1V1G1hSppoynBhxdk1V9XbSLGSPEcL0ZTKS2Jo9Y\nB/ipPTWWpD1jHvELTNEsgfcAgAmHsEIH7j9RhMy/YtsOczlymf+fqvcy5NJHzAaV\nFRomn/Eci/IYKGTc6/g3AzVQZ0aILEArgwiAUoumiWqco+ASVsjDY5by3Yxx3vsA\n+QIDAQAB\n-----END PUBLIC KEY-----\n"
40 |
41 | key, err := DecodePublicPEM(cert)
42 |
43 | require.Nil(t, err)
44 | require.NotNil(t, key)
45 | }
46 |
47 | func TestParseRSAKey3(t *testing.T) {
48 |
49 | cert := "-----BEGIN RSA PUBLIC KEY-----\nMEgCQQDbLVt+d4EGWdMOgG6lS2xvhP6kbb0OgdkG26jmqWfUCqzYhyuhoL3JgijV\nN+Y0Jbb4iEU2aQXMNHM+Rq1bfkLTAgMBAAE=\n-----END RSA PUBLIC KEY-----\n"
50 |
51 | key, err := DecodePublicPEM(cert)
52 |
53 | require.Nil(t, err)
54 | require.NotNil(t, key)
55 | }
56 |
57 | // This is a key from an example I found on the Internet
58 | func TestDecode_PKIXKey(t *testing.T) {
59 | cert := `-----BEGIN PUBLIC KEY-----
60 | MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAlRuRnThUjU8/prwYxbty
61 | WPT9pURI3lbsKMiB6Fn/VHOKE13p4D8xgOCADpdRagdT6n4etr9atzDKUSvpMtR3
62 | CP5noNc97WiNCggBjVWhs7szEe8ugyqF23XwpHQ6uV1LKH50m92MbOWfCtjU9p/x
63 | qhNpQQ1AZhqNy5Gevap5k8XzRmjSldNAFZMY7Yv3Gi+nyCwGwpVtBUwhuLzgNFK/
64 | yDtw2WcWmUU7NuC8Q6MWvPebxVtCfVp/iQU6q60yyt6aGOBkhAX0LpKAEhKidixY
65 | nP9PNVBvxgu3XZ4P36gZV6+ummKdBVnc3NqwBLu5+CcdRdusmHPHd5pHf4/38Z3/
66 | 6qU2a/fPvWzceVTEgZ47QjFMTCTmCwNt29cvi7zZeQzjtwQgn4ipN9NibRH/Ax/q
67 | TbIzHfrJ1xa2RteWSdFjwtxi9C20HUkjXSeI4YlzQMH0fPX6KCE7aVePTOnB69I/
68 | a9/q96DiXZajwlpq3wFctrs1oXqBp5DVrCIj8hU2wNgB7LtQ1mCtsYz//heai0K9
69 | PhE4X6hiE0YmeAZjR0uHl8M/5aW9xCoJ72+12kKpWAa0SFRWLy6FejNYCYpkupVJ
70 | yecLk/4L1W0l6jQQZnWErXZYe0PNFcmwGXy1Rep83kfBRNKRy5tvocalLlwXLdUk
71 | AIU+2GKjyT3iMuzZxxFxPFMCAwEAAQ==
72 | -----END PUBLIC KEY-----`
73 |
74 | key, err := DecodePublicPEM(cert)
75 | require.Nil(t, err)
76 | require.NotNil(t, key)
77 | }
78 |
79 | func TestDecode_MastodonPEM(t *testing.T) {
80 | pem := "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApIC6kVMMkk0cc4GCAQ+/\n5XAKz8KhSbSpsWFC1jVRH+AXDAH3O6Pw+r5xNUu+OEGUPnLXycxuPZapxTQPGw/w\nPUMk2QvEhi9WEECrdPG8y2dEZMcNvGhtOtyhIWHGBLfANcpeueW3Z8WBR2Eak5+Y\nkuy+oiS3CZ13JAxbY6AfNc6L1V1G1hSppoynBhxdk1V9XbSLGSPEcL0ZTKS2Jo9Y\nB/ipPTWWpD1jHvELTNEsgfcAgAmHsEIH7j9RhMy/YtsOczlymf+fqvcy5NJHzAaV\nFRomn/Eci/IYKGTc6/g3AzVQZ0aILEArgwiAUoumiWqco+ASVsjDY5by3Yxx3vsA\n+QIDAQAB\n-----END PUBLIC KEY-----\n"
81 | key, err := DecodePublicPEM(pem)
82 | require.Nil(t, err)
83 | require.NotNil(t, key)
84 | }
85 |
86 | func TestDecode_EmissaryPEM(t *testing.T) {
87 | pem := "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAy1xxGJw8d+FouEHikqkmNo/X8/tPAMtZtzXXj03Uzr3Pxfpy4a0M\nhZwd3duWqhINPKWbDFgn9W2z6I+nIziBLD+YxHWqvahpsRqGkmu86CoOLKommbUL\njAIzyAMtPqBpOQJ8xJtq6Evz09avUku08iPrjP64wKNESyu5mDFvfpW31F6B7C0y\n+QC6vbhDanOnvV9QIxMDEbU87iY3nyyt8ZkSj5I2bHb80LQ0BEWN4WkOZB+wc0+f\nhQ9+pJobSSsyGJ21graTbkEKcr1LGo+Xe+rqPYT1IcDwpMTD7es1AiqbZwlIxNoh\n9wvJygZsqB4Iok8iatc+I1fGl6XiJcnxAQIDAQAB\n-----END RSA PUBLIC KEY-----\n"
88 | key, err := DecodePublicPEM(pem)
89 | require.Nil(t, err)
90 | require.NotNil(t, key)
91 | }
92 |
--------------------------------------------------------------------------------
/sigs/constants.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | const FieldRequestTarget = "(request-target)"
4 |
5 | // FieldCreated is not supported at this time, and will generate an error.
6 | // https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures#section-2.3
7 | const FieldCreated = "(created)"
8 |
9 | // FieldExpires is not supported at this time, and will generate an error.
10 | // https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures#section-2.3
11 | const FieldExpires = "(expires)"
12 |
13 | const FieldDate = "date"
14 |
15 | // FieldDigest represents the Digest header field that validates the request body.
16 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Digest
17 | // https://datatracker.ietf.org/doc/draft-ietf-httpbis-digest-headers/
18 | const FieldDigest = "digest"
19 |
20 | const FieldHost = "host"
21 |
22 | // The “hs2019” signature algorithm. This is the only non-deprecated algorithm. Unlike the other algorithms, the hash and digest functions are not implied by the choice of this signature algorithm. Instead, the hash and digest functions are chosen based on the key used. RSA, HMAC, and ECDSA keys are all supported.
23 | // TODO: How to implement hs2019?
24 | const Algorithm_HS2019 = "hs2019"
25 |
26 | // Deprecated. The “rsa-sha256” signature algorithm. Deprecated by the standard because it reveals which hash and digest algorithm is used.
27 | const Algorithm_RSA_SHA256 = "rsa-sha256"
28 |
29 | const Algorithm_RSA_SHA512 = "rsa-sha512"
30 |
31 | // Deprecated. The “hmac-sha256” signature algorithm. Deprecated by the standard because it reveals which hash and digest algorithm is used.
32 | const Algorithm_HMAC_SHA256 = "hmac-sha256"
33 |
34 | // Deprecated. The “ecdsa-sha256” signature algorithm. Deprecated by the standard because it reveals which hash and digest algorithm is used.
35 | const Algorithm_ECDSA_SHA256 = "ecdsa-sha256"
36 |
37 | // TODO: Are these supported by the actual specs?
38 | const Algorithm_HMAC_SHA512 = "hmac-sha512"
39 | const Algorithm_ECDSA_SHA512 = "ecdsa-sha512"
40 |
--------------------------------------------------------------------------------
/sigs/digest.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "crypto"
5 | "net/http"
6 | "strings"
7 |
8 | "github.com/benpate/derp"
9 | "github.com/benpate/re"
10 | "github.com/benpate/rosetta/list"
11 | "github.com/benpate/rosetta/slice"
12 | "github.com/rs/zerolog/log"
13 | )
14 |
15 | // ApplyDigest calculates the digest of the body from a given
16 | // http.Request, then adds the digest to the Request's header.
17 | func ApplyDigest(request *http.Request, digestName string, digestFunc DigestFunc) error {
18 |
19 | if request == nil {
20 | return derp.InternalError("sigs.ApplyDigest", "Request cannot be nil")
21 | }
22 |
23 | // Retrieve the request body (in a replayable manner)
24 | body, err := re.ReadRequestBody(request)
25 |
26 | if err != nil {
27 | return derp.Wrap(err, "sigs.ApplyDigest", "Error reading request body")
28 | }
29 |
30 | if len(body) == 0 {
31 | return nil
32 | }
33 |
34 | // Try to calculate the digest with the DigestFunc
35 | result := digestName + "=" + digestFunc(body)
36 |
37 | // Apply the digest to the Request
38 | request.Header.Set(FieldDigest, result)
39 | return nil
40 | }
41 |
42 | // VerifyDigest verifies that the digest in the http.Request header
43 | // matches the contents of the http.Request body.
44 | func VerifyDigest(request *http.Request, allowedHashes ...crypto.Hash) error {
45 |
46 | // NILCHECK: Request cannot be nil
47 | if request == nil {
48 | return derp.InternalError("sigs.VerifyDigest", "Request cannot be nil")
49 | }
50 |
51 | // Retrieve the request body (in a replayable manner)
52 | body, err := re.ReadRequestBody(request)
53 |
54 | if err != nil {
55 | return derp.Wrap(err, "sigs.VerifyDigest", "Error reading request body")
56 | }
57 |
58 | // Retrieve the digest(s) included in the HTTP Request
59 | digestHeader := request.Header.Get(FieldDigest)
60 |
61 | // If there is no digest header, then there is nothing to verify
62 | if digestHeader == "" {
63 | return nil
64 | }
65 |
66 | // Process the digest header into separate values
67 | headerValues := strings.Split(digestHeader, ",")
68 | atLeastOneAlgorithmMatches := false
69 |
70 | for _, headerValue := range headerValues {
71 |
72 | headerValue = strings.Trim(headerValue, " ")
73 | digestAlgorithm, digestValue := list.Split(headerValue, '=')
74 |
75 | // If we recognize the digest algorithm, then use it to verify the body/digest
76 | fn, err := getDigestFuncByName(digestAlgorithm)
77 |
78 | if err != nil {
79 | log.Trace().Msg("Hannibal sigs: VerifyDigest: Unknown digest algorithm: " + digestAlgorithm)
80 | continue
81 | }
82 |
83 | // Additional trace values that helped isolate a bug in the digest algorithm
84 | log.Trace().Msg("Validating Digest: " + digestAlgorithm + "=" + digestValue)
85 | log.Trace().Msg(headerValue)
86 | log.Trace().Msg(digestValue)
87 | log.Trace().Msg(fn(body))
88 |
89 | // If the values match, then success!
90 | if digestValue == fn(body) {
91 | log.Trace().Msg("Hannibal sigs: VerifyDigest: Valid Digest Found. Algorithm: " + digestAlgorithm)
92 |
93 | // Verify that this algorithm is in the list of allowed hashes
94 | hash := getHashByName(digestAlgorithm)
95 | if slice.Contains(allowedHashes, hash) {
96 | atLeastOneAlgorithmMatches = true
97 | }
98 | continue
99 | }
100 |
101 | // If the values DON'T MATCH, then fail immediately.
102 | // We don't want bad actors "digest shopping"
103 | return derp.ForbiddenError("sigs.VerifyDigest", "Digest verification failed", digestValue)
104 | }
105 |
106 | // If we have found at least one digest that matches, then success!
107 | if atLeastOneAlgorithmMatches {
108 | log.Trace().Msg("Digest verified.")
109 | return nil
110 | }
111 |
112 | // Otherwise, the digest hash does not meet our minimum requirements. Fail.
113 | return derp.ForbiddenError("sigs.VerifyDigest", "No matching digest found")
114 | }
115 |
--------------------------------------------------------------------------------
/sigs/digestFunc.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "crypto"
5 | "crypto/sha256"
6 | "crypto/sha512"
7 | "encoding/base64"
8 | "strings"
9 |
10 | "github.com/benpate/derp"
11 | "github.com/rs/zerolog/log"
12 | )
13 |
14 | // DigestFunc defines a function that calculates the digest of a given byte array
15 | type DigestFunc func(body []byte) string
16 |
17 | // getDigestFuncByName returns the DigestFunc for either `sha-256` or `sha-512`
18 | func getDigestFuncByName(name string) (DigestFunc, error) {
19 | return getDigestFunc(getHashByName(name))
20 | }
21 |
22 | // getDigestFunc uses an algorithm name to generate a DigestFunc using
23 | // a case insensitive match. It currently supports `sha-256` and `sha-512`.
24 | // Unrecognized digest names will return an error.
25 | func getDigestFunc(algorithm crypto.Hash) (DigestFunc, error) {
26 |
27 | switch algorithm {
28 |
29 | case crypto.SHA256:
30 | return DigestSHA256, nil
31 |
32 | case crypto.SHA512:
33 | return DigestSHA512, nil
34 | }
35 |
36 | return nil, derp.BadRequestError("sigs.getDigestFunc", "Unknown algorithm", algorithm)
37 | }
38 |
39 | // getDigestName returns the name of a given crypto.Hash value
40 | func getDigestName(algorithm crypto.Hash) string {
41 |
42 | switch algorithm {
43 |
44 | case crypto.SHA256:
45 | return "SHA-256"
46 |
47 | case crypto.SHA512:
48 | return "SHA-512"
49 | }
50 |
51 | return "unknown"
52 | }
53 |
54 | // getHashByName converts common hash names into crypto.Hash values. It works
55 | // with these values: sha-256, sha256, sha-512, sha512 (case insensitive)
56 | func getHashByName(name string) crypto.Hash {
57 |
58 | switch strings.ToLower(name) {
59 |
60 | case "sha-256", "sha256":
61 | return crypto.SHA256
62 |
63 | case "sha-512", "sha512":
64 | return crypto.SHA512
65 | }
66 |
67 | log.Warn().Msg("sigs.getHashByName: Unknown hash name: " + name + ". Defaulting to SHA-256")
68 |
69 | return crypto.SHA256
70 | }
71 |
72 | // DigestSHA256 calculates the SHA-256 digest of a slice of bytes
73 | func DigestSHA256(body []byte) string {
74 | digest := sha256.Sum256(body)
75 | return base64.StdEncoding.EncodeToString(digest[:])
76 | }
77 |
78 | // DigestSHA512 calculates the SHA-512 digest of a given slice of bytes
79 | func DigestSHA512(body []byte) string {
80 | digest := sha512.Sum512(body)
81 | return base64.StdEncoding.EncodeToString(digest[:])
82 | }
83 |
84 | // TODO: Additional algorithms specified by https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Digest
85 | // unixsum, unixcksum, crc32c, sha-256 and sha-512, id-sha-256, id-sha-512
86 |
--------------------------------------------------------------------------------
/sigs/digest_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "bytes"
5 | "crypto"
6 | "net/http"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func TestApplyDigest(t *testing.T) {
13 |
14 | var body bytes.Buffer
15 | body.WriteString("This is my body. There are many like it, but this one is mine.")
16 |
17 | request, err := http.NewRequest("GET", "http://example.com/foo", &body)
18 | require.Nil(t, err)
19 |
20 | err = ApplyDigest(request, "SHA-256", DigestSHA256)
21 | require.Nil(t, err)
22 | require.Equal(t, "SHA-256=2dZxOmbiuR4yypVcyCfajB3YMhmSg+QNUlnUIrfllPM=", request.Header.Get("Digest"))
23 | }
24 |
25 | func TestVerifyDigest(t *testing.T) {
26 |
27 | body := []byte("This is my body. There are many like it, but this one is mine.")
28 |
29 | request, err := http.NewRequest("GET", "http://example.com/foo", bytes.NewReader(body))
30 | require.Nil(t, err)
31 |
32 | err = ApplyDigest(request, "SHA-256", DigestSHA256)
33 | require.Nil(t, err)
34 |
35 | err = VerifyDigest(request, crypto.SHA256)
36 | require.Nil(t, err)
37 | }
38 |
39 | func TestVerifyDigest_PixelFed(t *testing.T) {
40 |
41 | request := getTestPixelFedRequest()
42 |
43 | err := VerifyDigest(request, crypto.SHA256)
44 | require.Nil(t, err)
45 | }
46 |
--------------------------------------------------------------------------------
/sigs/httpsig.go:
--------------------------------------------------------------------------------
1 | // Package sigs implements the IETF draft specification "Signing HTTP Messages"
2 | // https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures
3 | package sigs
4 |
--------------------------------------------------------------------------------
/sigs/remoteMiddleware.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/benpate/derp"
7 | "github.com/benpate/remote"
8 | )
9 |
10 | // WithSigner is a remote.Option that signs an outbound HTTP request
11 | func WithSigner(signer Signer) remote.Option {
12 |
13 | return remote.Option{
14 |
15 | ModifyRequest: func(txn *remote.Transaction, request *http.Request) *http.Response {
16 |
17 | // Sign the outbound request
18 | if err := signer.Sign(request); err != nil {
19 | derp.Report(derp.Wrap(err, "hannibal.sigs.WithSigner", "Error signing request"))
20 | }
21 |
22 | // If exists, write the Digest back into the transaction (for serialization, et al)
23 | if digest := request.Header.Get("Digest"); digest != "" {
24 | txn.Header("Digest", digest)
25 | }
26 |
27 | // If exists, write the Signature back into the transaction (for serialization, et al)
28 | if signature := request.Header.Get("Signature"); signature != "" {
29 | txn.Header("Signature", signature)
30 | }
31 |
32 | // Nil response means that we are still sending the request to the remote server
33 | // instead of replacing it with a new request.
34 | return nil
35 | },
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/sigs/request_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "bytes"
5 | "net/http"
6 | "strings"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func TestHTTPRequest(t *testing.T) {
13 |
14 | request, err := http.NewRequest("GET", "http://example.com/foo", nil)
15 | require.Nil(t, err)
16 |
17 | request.Header.Add("Cache-Control", "max-age=60")
18 | request.Header.Add("Cache-Control", "must-revalidate")
19 |
20 | var buffer bytes.Buffer
21 | if err := request.Header.Write(&buffer); err != nil {
22 | panic(err)
23 | }
24 |
25 | // Guarantee that Go is writing the header the way we'd expect
26 | require.Equal(t, removeTabs("Cache-Control: max-age=60\r\nCache-Control: must-revalidate\r\n"), buffer.String())
27 |
28 | // Prove that we get the FIRST value when we call .Get()
29 | require.Equal(t, "max-age=60", request.Header.Get("Cache-Control"))
30 | require.Equal(t, "max-age=60", request.Header.Get("cAcHe-CoNtRoL"))
31 |
32 | // Prove that whe get ALL values when whe access via the map
33 | valueSlice := request.Header[http.CanonicalHeaderKey("CAcHe-CoNtroL")]
34 | result := strings.Join(valueSlice, ", ")
35 |
36 | require.Equal(t, "max-age=60, must-revalidate", result)
37 | }
38 |
--------------------------------------------------------------------------------
/sigs/signature_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "encoding/base64"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | var testString string
11 | var testBytes []byte
12 |
13 | func init() {
14 | testString = "Y2FiYWIxNGRiZDk4ZA=="
15 | testBytes, _ = base64.StdEncoding.DecodeString(testString)
16 | }
17 |
18 | func TestParseSignature_IETF(t *testing.T) {
19 |
20 | // From: https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-05#section-4.1.1
21 | headerValue := `keyId="rsa-key-1",algorithm="hs2019",
22 | headers="(request-target) (created) host digest content-length",
23 | signature="` + testString + `"`
24 |
25 | signature, err := ParseSignature(headerValue)
26 |
27 | require.Nil(t, err)
28 | require.Equal(t, "rsa-key-1", signature.KeyID)
29 | require.Equal(t, "hs2019", signature.Algorithm)
30 | require.Equal(t, []string{"(request-target)", "(created)", "host", "digest", "content-length"}, signature.Headers)
31 | require.Equal(t, testBytes, signature.Signature)
32 | }
33 |
34 | func TestParseSignature_Mastodon(t *testing.T) {
35 |
36 | // From: https://docs.joinmastodon.org/spec/security/
37 | headerValue := `keyId="https://my-example.com/actor#main-key",headers="(request-target) host date digest",signature="` + testString + `"`
38 |
39 | signature, err := ParseSignature(headerValue)
40 |
41 | require.Nil(t, err)
42 | require.Equal(t, "https://my-example.com/actor#main-key", signature.KeyID)
43 | require.Equal(t, "", signature.Algorithm)
44 | require.Equal(t, []string{"(request-target)", "host", "date", "digest"}, signature.Headers)
45 | require.Equal(t, testBytes, signature.Signature)
46 | }
47 |
48 | func TestParseSignature_MastodonWithWhitespace(t *testing.T) {
49 |
50 | // From: https://docs.joinmastodon.org/spec/security/
51 | // Adding spaces to the signature just in case others do this too.
52 | headerValue := `keyId="https://my-example.com/actor#main-key", headers="(request-target) host date digest", signature="` + testString + `"`
53 |
54 | signature, err := ParseSignature(headerValue)
55 |
56 | require.Nil(t, err)
57 | require.Equal(t, "https://my-example.com/actor#main-key", signature.KeyID)
58 | require.Equal(t, "", signature.Algorithm)
59 | require.Equal(t, []string{"(request-target)", "host", "date", "digest"}, signature.Headers)
60 | require.Equal(t, testBytes, signature.Signature)
61 | }
62 |
--------------------------------------------------------------------------------
/sigs/signerOption.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import "crypto"
4 |
5 | // SignerOption is a function that modifies a Signer
6 | type SignerOption func(*Signer)
7 |
8 | // SignerFields sets the http.Request fields to be signed
9 | func SignerFields(fields ...string) SignerOption {
10 | return func(signer *Signer) {
11 | signer.Fields = fields
12 | }
13 | }
14 |
15 | // SignerSignatureDigest sets the hashing algorithm to be used
16 | // when we sign a request.
17 | func SignerSignatureHash(hash crypto.Hash) SignerOption {
18 | return func(signer *Signer) {
19 | signer.SignatureHash = hash
20 | }
21 | }
22 |
23 | // SignerBodyDigests sets the digest algorithm to be used
24 | // when creating the "Digest" header.
25 | func SignerBodyDigest(digest crypto.Hash) SignerOption {
26 | return func(signer *Signer) {
27 | signer.BodyDigest = digest
28 | }
29 | }
30 |
31 | func SignerCreated(created int64) SignerOption {
32 | return func(signer *Signer) {
33 | signer.Created = created
34 | }
35 | }
36 |
37 | func SignerExpires(expires int64) SignerOption {
38 | return func(signer *Signer) {
39 | signer.Expires = expires
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/sigs/signer_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "bytes"
5 | "crypto"
6 | "crypto/rand"
7 | "crypto/rsa"
8 | "encoding/base64"
9 | "net/http"
10 | "net/url"
11 | "strings"
12 | "testing"
13 |
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | func TestSignRequest(t *testing.T) {
18 |
19 | body := []byte("This is the body of the request")
20 |
21 | request, err := http.NewRequest("GET", "http://example.com/something?test=true", bytes.NewReader(body))
22 | require.Nil(t, err)
23 | request.Header.Set("Content-Type", "text/plain")
24 |
25 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
26 | require.Nil(t, err)
27 |
28 | err = Sign(request, "test-key", privateKey)
29 | require.Nil(t, err)
30 |
31 | require.Equal(t, "SHA-256=65F8+S1Bg7oPQS/fIxVg4x7PoLWnOxWlGMFB/hafojg=", request.Header.Get("Digest"))
32 | require.NotEmpty(t, request.Header.Get("Signature"))
33 | }
34 |
35 | func TestMakePlaintext(t *testing.T) {
36 |
37 | body := []byte("This is the body of the request")
38 |
39 | request, err := http.NewRequest("GET", "http://example.com/something?test=true", bytes.NewReader(body))
40 | require.Nil(t, err)
41 | request.Header.Set("Content-Type", "text/plain")
42 |
43 | signature := NewSignature()
44 | result := makePlaintext(request, signature, FieldRequestTarget, FieldHost, FieldDate, FieldDigest)
45 | expected := removeTabs(
46 | `(request-target): get /something?test=true
47 | host: example.com
48 | date:
49 | digest: `)
50 | require.Equal(t, expected, result)
51 | }
52 |
53 | func TestMakePlaintext_Alternate(t *testing.T) {
54 |
55 | bodyReader := strings.NewReader("This is the body of the request")
56 |
57 | request, err := http.NewRequest("GET", "http://example.com/something?test=true", bodyReader)
58 | require.Nil(t, err)
59 | request.Header.Set("Content-Type", "text/plain")
60 |
61 | signature := NewSignature()
62 | result := makePlaintext(request, signature, FieldRequestTarget, FieldHost, "Content-Type")
63 | expected := removeTabs(
64 | `(request-target): get /something?test=true
65 | host: example.com
66 | content-type: text/plain`)
67 |
68 | require.Equal(t, expected, result)
69 | }
70 |
71 | func TestMakeSignatureHash_SHA256(t *testing.T) {
72 | result, err := makeSignatureHash("This is digest-able", crypto.SHA256)
73 | require.Nil(t, err)
74 |
75 | actual := base64.StdEncoding.EncodeToString(result)
76 | require.Equal(t, "jlBmJDmZdMjhLZga/ZjDrlloKd5lukG9S0lu/f7Xc64=", actual)
77 | }
78 |
79 | func TestMakeSignatureHash_SHA512(t *testing.T) {
80 | result, err := makeSignatureHash("This is digest-able", crypto.SHA512)
81 | require.Nil(t, err)
82 |
83 | actual := base64.StdEncoding.EncodeToString(result)
84 | require.Equal(t, "s2JJ/rYbVQTrkNR440jq+wuNk9ktJgvmVSDq805iC0EP4ONQPwfvuQK0yR/YuX7riJtNRwxMq6R1GL8W7A5vzg==", actual)
85 | }
86 |
87 | func TestMakeSignatureHash_Unsupported(t *testing.T) {
88 | result, err := makeSignatureHash("This is digest-able", crypto.BLAKE2b_256)
89 | require.NotNil(t, err)
90 | require.Nil(t, result)
91 | }
92 |
93 | func TestGetPathAndQuery(t *testing.T) {
94 | url, _ := url.Parse("http://example.com")
95 | require.Equal(t, "/", getPathAndQuery(url))
96 |
97 | url, _ = url.Parse("http://example.com/")
98 | require.Equal(t, "/", getPathAndQuery(url))
99 |
100 | url, _ = url.Parse("http://example.com/something")
101 | require.Equal(t, "/something", getPathAndQuery(url))
102 |
103 | url, _ = url.Parse("http://example.com/something?test=true")
104 | require.Equal(t, "/something?test=true", getPathAndQuery(url))
105 | }
106 |
107 | func removeTabs(s string) string {
108 | return strings.ReplaceAll(s, "\t", "")
109 | }
110 |
--------------------------------------------------------------------------------
/sigs/test_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "bytes"
5 | "net/http"
6 |
7 | "github.com/rs/zerolog"
8 | )
9 |
10 | func init() {
11 | // Configure logging
12 | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
13 | zerolog.SetGlobalLevel(zerolog.WarnLevel)
14 | }
15 |
16 | func must[T any](value T, err error) T {
17 | if err != nil {
18 | panic(err)
19 | }
20 | return value
21 | }
22 |
23 | func getTestPixelFedRequest() *http.Request {
24 | var body bytes.Buffer
25 | body.WriteString(`{"@context":"https:\/\/www.w3.org\/ns\/activitystreams","id":"https:\/\/pixelfed.social\/users\/benpate#follow\/595731146082391369","type":"Follow","actor":"https:\/\/pixelfed.social\/users\/benpate","object":"https:\/\/emdev.ddns.net\/@64d68054a4bf39a519f27c67"}`)
26 |
27 | request, err := http.NewRequest("GET", "https://emdev.ddns.net/@64d68054a4bf39a519f27c67/pub/inbox", &body)
28 |
29 | if err != nil {
30 | panic(err)
31 | }
32 |
33 | request.Header.Set("User-Agent", "(Pixelfed/0.11.9; +https://pixelfed.social)")
34 | request.Header.Set("Content-Type", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`)
35 | request.Header.Set("Date", "Mon, 04 Sep 2023 21:17:36 GMT")
36 | request.Header.Set("Digest", "SHA-256=TwwjRc4l0VffR6UXoebZctDg2CY/sxUciFKxzVC3kPo=")
37 | request.Header.Set("Signature", `keyId="https://pixelfed.social/users/benpate#main-key",headers="(request-target) host date digest content-type user-agent",algorithm="rsa-sha256",signature="ZYIfR4fUvNt7K/2iWxke83wOJCPqnRNhJqPV3Z8NisTeaEXc1ujYAGahTyAUYYY1hKPDJL6HcbPszG5R/7yXUfQVoABDBeWN6k8pVm43FpfCic156qCczvGM6KqzhQtWrw4nYuILzdL+QCJo7O9H6TEsLAuVcJ7ycb5BpiNvOMy9pMnLAyvf8A3qxhh9NYm+PtzFczQ83HBCDtpr7N+wMvP1xhIByouaB0VLntsyjpjJZdQSmZteSiZixN3h27lkBI/++xLdTKbff81dwdMEVROf4HUp/TR5kmh6NotoV7bTGxn/0c05Bv4bpNaMQc8f5myn/r4MuTHwS4pTSlhe/w=="`)
38 |
39 | return request
40 | }
41 |
--------------------------------------------------------------------------------
/sigs/utils.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import "github.com/rs/zerolog"
4 |
5 | // canInfo returns TRUE if zerolog is configured to allow Info logs
6 | // nolint:unused
7 | func canInfo() bool {
8 | return canLog(zerolog.InfoLevel)
9 | }
10 |
11 | // canDebug returns TRUE if zerolog is configured to allow Debug logs
12 | // nolint:unused
13 | func canDebug() bool {
14 | return canLog(zerolog.DebugLevel)
15 | }
16 |
17 | // canTrace returns TRUE if zerolog is configured to allow Trace logs
18 | // nolint:unused
19 | func canTrace() bool {
20 | return canLog(zerolog.TraceLevel)
21 | }
22 |
23 | // canLog is a silly zerolog helper that returns TRUE
24 | // if the provided log level would be allowed
25 | // (based on the global log level).
26 | // This makes it easier to execute expensive code conditionally,
27 | // for instance: marshalling a JSON object for logging.
28 | func canLog(level zerolog.Level) bool {
29 | return zerolog.GlobalLevel() <= level
30 | }
31 |
--------------------------------------------------------------------------------
/sigs/utils_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/rs/zerolog"
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | func TestCanTrace(t *testing.T) {
11 | zerolog.SetGlobalLevel(zerolog.TraceLevel)
12 | require.True(t, canTrace())
13 | zerolog.SetGlobalLevel(zerolog.DebugLevel)
14 | require.False(t, canTrace())
15 | }
16 |
17 | func TestCanDebug(t *testing.T) {
18 | zerolog.SetGlobalLevel(zerolog.TraceLevel)
19 | require.True(t, canDebug())
20 | zerolog.SetGlobalLevel(zerolog.DebugLevel)
21 | require.True(t, canDebug())
22 | zerolog.SetGlobalLevel(zerolog.InfoLevel)
23 | require.False(t, canDebug())
24 | }
25 |
26 | func TestCanInfo(t *testing.T) {
27 | zerolog.SetGlobalLevel(zerolog.TraceLevel)
28 | require.True(t, canDebug())
29 | zerolog.SetGlobalLevel(zerolog.DebugLevel)
30 | require.True(t, canDebug())
31 | zerolog.SetGlobalLevel(zerolog.InfoLevel)
32 | require.True(t, canInfo())
33 | zerolog.SetGlobalLevel(zerolog.WarnLevel)
34 | require.False(t, canInfo())
35 | }
36 |
--------------------------------------------------------------------------------
/sigs/verifierOption.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import "crypto"
4 |
5 | // VerifierOption is a function that modifies a Verifier
6 | type VerifierOption func(*Verifier)
7 |
8 | // VerifierFields sets the list of http.Request fields that
9 | // MUST ALL be present in the "Signature" header from a
10 | // remote server for a signature to be accepted. Extra
11 | // fields are allowed in the Signature, and will still
12 | // be verified.
13 | func VerifierFields(fields ...string) VerifierOption {
14 | return func(verifier *Verifier) {
15 | verifier.Fields = fields
16 | }
17 | }
18 |
19 | // VerifierDigests sets the list of algorithms that we will
20 | // accept from remote servers when they create a "Digest"
21 | // http header. ALL recognized digests must be valid to
22 | // pass, and AT LEAST ONE of the algorithms must be from
23 | // this list.
24 | func VerifierBodyDigests(digests ...crypto.Hash) VerifierOption {
25 | return func(verifier *Verifier) {
26 | verifier.BodyDigests = digests
27 | }
28 | }
29 |
30 | // VerifierSignatureHashes sets the hashing algorithms to use
31 | // when validating the "Signature" header. Hashes are tried
32 | // in order, and the FIRST successful match returns success.
33 | // If ALL hash attempts fail, then validation fails.
34 | func VerifierSignatureHashes(hashes ...crypto.Hash) VerifierOption {
35 | return func(verifier *Verifier) {
36 | verifier.SignatureHashes = hashes
37 | }
38 | }
39 |
40 | // VerifierTimeout sets the maximum age of a request and
41 | // signature (in seconds). Messages received after this
42 | // time duration will be rejected.
43 | // Default is 43200 seconds (12 hours).
44 | func VerifierTimeout(seconds int) VerifierOption {
45 | return func(verifier *Verifier) {
46 | verifier.Timeout = seconds
47 | }
48 | }
49 |
50 | // VerifierIgnoreTimeout sets the verifier to ignore
51 | // message and signature time stamps. This is useful
52 | // for testing signatures, but should not be used in
53 | // production.
54 | func VerifierIgnoreTimeout() VerifierOption {
55 | return func(verifier *Verifier) {
56 | verifier.Timeout = 0
57 | }
58 | }
59 |
60 | // VerifierIgnoreBodyDigest sets the verifier to ignore
61 | // the "Digest" header. This is useful for testing
62 | // but should not be used in production.
63 | func VerifierIgnoreBodyDigest() VerifierOption {
64 | return func(verifier *Verifier) {
65 | verifier.CheckDigest = false
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/sigs/verifier_mock.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/benpate/derp"
7 | )
8 |
9 | // MockVerifier contains all of the settings necessary to verify a request
10 | type MockVerifier struct {
11 | KeyID string
12 | Success bool
13 | }
14 |
15 | // NewMockVerifier returns a fully initialized Verifier
16 | func NewMockVerifier(keyID string, success bool) MockVerifier {
17 | result := MockVerifier{
18 | KeyID: keyID,
19 | Success: success,
20 | }
21 | return result
22 | }
23 |
24 | // Verify verifies the given http.Request
25 | func (mock *MockVerifier) Verify(request *http.Request, keyFinder PublicKeyFinder) (Signature, error) {
26 |
27 | if mock.Success {
28 | signature := NewSignature()
29 | signature.KeyID = mock.KeyID
30 | return signature, nil
31 | }
32 |
33 | return NewSignature(), derp.ForbiddenError("hannibal.sigs.MockVerifier.Verify", "MockVerifier is configured to fail")
34 | }
35 |
--------------------------------------------------------------------------------
/sigs/verifier_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "bytes"
5 | "crypto"
6 | "crypto/rand"
7 | "crypto/rsa"
8 | "crypto/sha256"
9 | "crypto/sha512"
10 | "net/http"
11 | "testing"
12 |
13 | "github.com/benpate/derp"
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | func TestVerify_Manual(t *testing.T) {
18 |
19 | // Create a new Request
20 | var body bytes.Buffer
21 | body.WriteString("This is the body of the request")
22 |
23 | request, err := http.NewRequest("GET", "http://example.com/something?test=true", &body)
24 | require.Nil(t, err)
25 | request.Header.Set("Content-Type", "text/plain")
26 |
27 | // Create a Private Key to sign the request
28 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
29 | require.Nil(t, err)
30 |
31 | // Sign the Request
32 | err = Sign(request, "test-key", privateKey)
33 | require.Nil(t, err)
34 |
35 | require.Equal(t, "SHA-256=65F8+S1Bg7oPQS/fIxVg4x7PoLWnOxWlGMFB/hafojg=", request.Header.Get("Digest"))
36 | require.NotEmpty(t, request.Header.Get("Signature"))
37 |
38 | // Verify the Request
39 | keyFinder := func(keyID string) (string, error) {
40 | return EncodePublicPEM(privateKey), nil
41 | }
42 |
43 | _, err = Verify(request, keyFinder)
44 | require.Nil(t, err)
45 | }
46 |
47 | func TestSignAndVerify_RSA_SHA256(t *testing.T) {
48 |
49 | // Create an RSA key
50 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
51 | require.Nil(t, err)
52 |
53 | // Create a test digest
54 | digest := sha256.Sum256([]byte("this is the message"))
55 |
56 | // Sign the digest
57 | signature, err := makeSignedDigest(digest[:], crypto.SHA256, privateKey)
58 | require.Nil(t, err)
59 |
60 | err = verifySignature(&privateKey.PublicKey, crypto.SHA256, digest[:], signature)
61 | require.Nil(t, err)
62 | }
63 |
64 | func TestSignAndVerify_RSA_SHA512(t *testing.T) {
65 |
66 | // Create an RSA key
67 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
68 | require.Nil(t, err)
69 |
70 | // Create a test digest
71 | digest := sha512.Sum512([]byte("this is the message"))
72 |
73 | // Sign the digest
74 | signature, err := makeSignedDigest(digest[:], crypto.SHA512, privateKey)
75 | require.Nil(t, err)
76 |
77 | err = verifySignature(&privateKey.PublicKey, crypto.SHA512, digest[:], signature)
78 | require.Nil(t, err)
79 | }
80 |
81 | func TestSignAndVerify_Emissary(t *testing.T) {
82 |
83 | privateKey, publicKey := getTestKeys()
84 | require.NotNil(t, privateKey)
85 | require.NotNil(t, publicKey)
86 |
87 | plaintextSignature := removeTabs(
88 | `(request-target): post /@63f3c38ab0d6029bf1c915c9/pub/inbox
89 | host: 127.0.0.1
90 | date: Sat, 02 Sep 2023 15:29:05 GMT
91 | digest: SHA-256=VgRwIlTTedMmiNk71Zlod3Bq6gXOzJNIRlUXenyNdLw=`)
92 |
93 | hashedSignature, err := makeSignatureHash(plaintextSignature, crypto.SHA256)
94 | require.Nil(t, err)
95 |
96 | signedSignature, err := makeSignedDigest(hashedSignature, crypto.SHA256, privateKey)
97 | require.Nil(t, err)
98 |
99 | derp.Report(verifyHashAndSignature(plaintextSignature, crypto.SHA256, publicKey, signedSignature))
100 | }
101 |
102 | func getTestKeys() (crypto.PrivateKey, crypto.PublicKey) {
103 |
104 | privateKeyPEM := removeTabs(
105 | `-----BEGIN RSA PRIVATE KEY-----
106 | MIIEpQIBAAKCAQEAy1xxGJw8d+FouEHikqkmNo/X8/tPAMtZtzXXj03Uzr3Pxfpy
107 | 4a0MhZwd3duWqhINPKWbDFgn9W2z6I+nIziBLD+YxHWqvahpsRqGkmu86CoOLKom
108 | mbULjAIzyAMtPqBpOQJ8xJtq6Evz09avUku08iPrjP64wKNESyu5mDFvfpW31F6B
109 | 7C0y+QC6vbhDanOnvV9QIxMDEbU87iY3nyyt8ZkSj5I2bHb80LQ0BEWN4WkOZB+w
110 | c0+fhQ9+pJobSSsyGJ21graTbkEKcr1LGo+Xe+rqPYT1IcDwpMTD7es1AiqbZwlI
111 | xNoh9wvJygZsqB4Iok8iatc+I1fGl6XiJcnxAQIDAQABAoIBAGANotG4AguxqU/W
112 | ttkFEiqVWLBCFHfQlOingtCKN6kLGJdvi1Gy9gYpziWbcZeU/TGXGxwCi6UuEtsW
113 | 9x/4sXKf+11YIrSAVqOzXrrMLqcOLjHEkITrca/I3oJrlbRN+kVWOm525lEghuOZ
114 | NKhPYAE7HCg1rDg5JanH1lrfhsUnxF7XXhR48+8T1iifd31DpY6M3FAP4fGSdsOA
115 | 23bW7VG/nOwKrZ/V6ij0F3KvJ5dCOc1tw5SwhlTH9kuEoMq8Uy0i+3ngNkv7e0qJ
116 | k2/0B7nc6B6HP+nsswN3KoY4qKvPfktZDZeowmcNrJOBiw0N3PBrOIOI5UjkwBUN
117 | TOfnRUECgYEA1J1WMeag0Mr3d/rVRVqTXlwEkF7T9s7+MAPfJ5vF6u5ndrZdwy6g
118 | 6WdbiJMkUbAH1QFQ1q7MTNoo9L72dRkS5cnSwv7WLBC2Oy3ZpsBT9Lc9SZ7GAj/X
119 | 9x3XstBBIGkPMAfva/OCACPKd3Z84gTeDNTU+cYghv+EYhLd7UKhQ1kCgYEA9Nu3
120 | bpS2FguZ/CwkmRQTgTGAGysETjgLFHDxODEZPKBkTpnAf49In+Vx6BixZ6xqRWe9
121 | lSnlF20x8+pUOqhMpfnwxx+OEMayr4dGMDhDj6pQ+D8ETEOQ9PXqyuyyHq8FfIAH
122 | kdXddjCS8SSreBMgzjUZjn/md+ArA6UMN9T1LekCgYEAop/13hVZzFpzDwJ9Pp8Z
123 | OYOIuiTOXGnXY0KS3ej4acoQuWykKzbvPZghG0Xw8cqDMxnei1cITYBQ82NdgBO9
124 | sKW+4AesKeheesWHRVS24ueFqVoYen/64Lmi0tMX/YJea46mQxvuw8ycgOPQgdDX
125 | R1lDzgkNuDSZParQtTnRv4ECgYEAq/3RgPkwVZfcl8ciBeyWLr9obqzun0q6badP
126 | qNrEEVPQYW2aS3+H0djHA/KkWmA/XXUbM7Vz19q5pc1JUNJ61HMV76h4j8wiIy1v
127 | 3dsHidhme5k4GaG0Jny+ab+M9gSWY/dCWevRXX2NGZlaYEN/XZjq1K9+YWGylSLP
128 | zD/n4FECgYEAssvQs++XNyNi1YQvUOX0oZjKH1l9sx/JdE+O+UYsvOsCMRXPfLu4
129 | ipiwM6zWyC/tumuDlM9qReAKcqBtSNUOHcd9Tbs5zTUvm3RbhqUdgMPZfVG/O9qp
130 | 7FXferRokfJyjJpo7SW8K57HKPJ4aNMpa1yhG+uZ1rXzqvjD6zwRvto=
131 | -----END RSA PRIVATE KEY-----`)
132 |
133 | privateKey, err := DecodePrivatePEM(privateKeyPEM)
134 |
135 | if err != nil {
136 | panic(err)
137 | }
138 |
139 | testPrivateKey := privateKey.(*rsa.PrivateKey)
140 | testPublicKeyPEM := EncodePublicPEM(testPrivateKey)
141 |
142 | // in database
143 | publicKeyPEM := removeTabs(
144 | `-----BEGIN RSA PUBLIC KEY-----
145 | MIIBCgKCAQEAy1xxGJw8d+FouEHikqkmNo/X8/tPAMtZtzXXj03Uzr3Pxfpy4a0M
146 | hZwd3duWqhINPKWbDFgn9W2z6I+nIziBLD+YxHWqvahpsRqGkmu86CoOLKommbUL
147 | jAIzyAMtPqBpOQJ8xJtq6Evz09avUku08iPrjP64wKNESyu5mDFvfpW31F6B7C0y
148 | +QC6vbhDanOnvV9QIxMDEbU87iY3nyyt8ZkSj5I2bHb80LQ0BEWN4WkOZB+wc0+f
149 | hQ9+pJobSSsyGJ21graTbkEKcr1LGo+Xe+rqPYT1IcDwpMTD7es1AiqbZwlIxNoh
150 | 9wvJygZsqB4Iok8iatc+I1fGl6XiJcnxAQIDAQAB
151 | -----END RSA PUBLIC KEY-----
152 | `)
153 |
154 | if publicKeyPEM != testPublicKeyPEM {
155 | panic("Public PEMs do not match")
156 | }
157 |
158 | publicKey, err := DecodePublicPEM(publicKeyPEM)
159 |
160 | if err != nil {
161 | panic(err)
162 | }
163 |
164 | return privateKey, publicKey
165 | }
166 |
--------------------------------------------------------------------------------
/sigs/x_funfedi_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "bufio"
5 | "crypto"
6 | "net/http"
7 | "strings"
8 | "testing"
9 |
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestFunFedi(t *testing.T) {
14 |
15 | // From transactions sent by https://verify.funfedi.dev/
16 | // FunFedi is a test suite that uses different capitalization for the Digest header (sha-256 instead of SHA-256)
17 | // This test ensures that the Digest header is case-insensitive.
18 |
19 | rawHTTP := removeTabs(
20 | `POST /@64d68054a4bf39a519f27c67/pub/inbox HTTP/1.1
21 | Host: emdev.ddns.net
22 | Digest: sha-256=27p0TuEIcJbNLBjv/RQFROHFxe0K74PK2exvfyHkkDQ=
23 | Content-Type: application/activity+json
24 | Signature: keyId="https://verify.funfedi.dev/bob#main",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="b2k1vPoLJpuCk1MmAEk6pfWi5G8SFALBqOjywUNdOEiC9SeTEPULCDi5quLPqzlvsSoD+jHipzTlETYwnen9wkwYqKzBlp5sTMbdKEXI1L6dzE4mmqMqE5zCGgzJqAlK59Z7PQZGTegJ/qAXjywBPcJC7TB4yD9JpbNPBJ6DcqBk3wGMh0rTxMNg4m9Wj90lrmYF+fqNxUkUHPdXxG7TxlaiQ18Z5RWZoXGv0+lOpNrhRU44J9Dl98aiKnhm+xoRrE+QUBKLEmKpwJU+bBsd1R7s9IV6P2JjYL2paOWIOveaNt41GcPHUc5g5aUkQfmMbWVeWv6VM7lTzpfO3e93Ww=="
25 | Accept: */*
26 | Accept-Encoding: gzip, deflate
27 | Content-Length: 207
28 | User-Agent: bovine/0.5.3
29 | Date: Tue, 05 Dec 2023 21:22:25 GMT
30 |
31 | {"@context": "https://www.w3.org/ns/activitystreams", "type": "Like", "actor": "https://verify.funfedi.dev/bob", "id": "https://verify.funfedi.dev/bobOEFNNp884mw", "object": "https://verify.funfedi.dev/bob"}
32 | `)
33 |
34 | keyFinder := func(keyID string) (string, error) {
35 | return "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo/r0o1lp0IUe6Y+IFm6Q\naHmMkyGXdHy9mE4l7+5AKQBGb8c3n6dDVIiECvrdmF1H8U1lsI/Q1nq8lQkuzxBV\nysmAPHFusW0ODy1NYGTEGYGnjfWuttltYGf8JgSzQMxUFnzg2PVXCmAq+QK3eENK\nm0xMc1EKagY5BBOtOljAP2iN0gdsb3RQ7mQHzBcZCataiMI52qVt/M/7Zony5W8e\nQWbLMPr3WMs+JPwz5TIVED4UMJxFswS5+yI1iQjgHgXdcw63ipJ/QWy/dtDU8llD\ne0TVR+KdKTxHpl2P3ky+OK6zYIO2MFfru8IDrax4i/zK1VTMzd9BipmoFdlK/5dw\n3wIDAQAB\n-----END PUBLIC KEY-----", nil
36 | }
37 |
38 | // Make a new request
39 | request, err := http.ReadRequest(bufio.NewReader(strings.NewReader(rawHTTP)))
40 | require.Nil(t, err)
41 |
42 | err = VerifyDigest(request, crypto.SHA256)
43 | require.Nil(t, err)
44 |
45 | // Verify the request
46 | _, err = Verify(request, keyFinder, VerifierIgnoreTimeout())
47 | require.Nil(t, err)
48 |
49 | }
50 |
--------------------------------------------------------------------------------
/sigs/x_pixelfed_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "net/http"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func Test_PixelFed(t *testing.T) {
13 |
14 | raw := removeTabs(`POST /@64d68054a4bf39a519f27c67/pub/inbox HTTP/1.1
15 | Host: emdev.ddns.net
16 | Accept: */*
17 | Date: Sun, 10 Sep 2023 01:12:22 GMT
18 | Digest: SHA-256=CjcIHEJJriyG8aC9K7mayMOi0drhBb2i4fvGI0t8phk=
19 | Content-Type: application/ld+json; profile="https://www.w3.org/ns/activitystreams"
20 | User-Agent: (Pixelfed/0.11.9; +https://pixelfed.social)
21 | Signature: keyId="https://pixelfed.social/users/benpate#main-key",headers="(request-target) host date digest content-type user-agent",algorithm="rsa-sha256",signature="cxaG7js7JxV5jvnHPqZQhLbIS47BX53A79DGNmVeKUYWQJk5sbsIgC+xvYxc7mZan2yI3EKNyrV/X61hAX1DyexeoGzGSAOmTnC0OdrniaWb3T71Supnej/1In0EuiL0+IXgqOH1AncwnZnYODBYOFOYgtoh2jWlmqKI8uE/L3iKP1nIhN8mUOf//6AqkkwNjz4PmPJ6nS1+gGckszjD1zxjFv2ncgo4rY4izSGVFdAU4QSA8ds3W6qIvha4nRoYeH8ZSzQjaIrM2owa62KgguonbBUa0NGNQHC5RxySj8Kzhw/AmIccKaJw7ythHAH9Km/zDflOVWZ62uurlv7Ikg=="
22 | Content-Length: 423
23 |
24 | {"@context":"https:\/\/www.w3.org\/ns\/activitystreams","id":"https:\/\/pixelfed.social\/users\/benpate#follow\/595731146082391369\/undo","type":"Undo","actor":"https:\/\/pixelfed.social\/users\/benpate","object":{"id":"https:\/\/pixelfed.social\/users\/benpate#follows\/595731146082391369","actor":"https:\/\/pixelfed.social\/users\/benpate","object":"https:\/\/emdev.ddns.net\/@64d68054a4bf39a519f27c67","type":"Follow"}}`)
25 |
26 | keyFinder := func(keyID string) (string, error) {
27 | return removeTabs(
28 | `-----BEGIN PUBLIC KEY-----
29 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsuI80UzaF1gQinBAZnz7
30 | CtqH4Rr6Booyii2ik+6Dw6nyuu//1VJVgWkck9xzGP1e0h2pqHNlqvsPxwjxw22o
31 | J31+hdU9mSOrK3C0k6nBT+RPEgfyj1UCXGk1lzfpKmgriftMGQ2kOQokRLqyKfXJ
32 | P7tizc3YUAGHw7pBWn/yV6SqMDyztvTF8Bzqx1zG4dvRLDpRWdVOZQkpZ+mrczt7
33 | TaYiZWkxSjZw0fsnoDXiDHX7cljq67Yrzu0WmeAR7fYAJgCxlC++557w95xY58Z9
34 | kIbcWXx0AExo/ed1GFNqFwg9Rdx58PzmA8dT9UpBOo9z6lu4KlbuWFYHz7b8HHGs
35 | rwIDAQAB
36 | -----END PUBLIC KEY-----`), nil
37 | }
38 |
39 | requestReader := bufio.NewReader(bytes.NewReader([]byte(raw)))
40 | request := must(http.ReadRequest(requestReader))
41 |
42 | _, err := Verify(request, keyFinder, VerifierIgnoreTimeout())
43 | require.Nil(t, err)
44 | }
45 |
--------------------------------------------------------------------------------
/streams/README.md:
--------------------------------------------------------------------------------
1 | ## Hannibal / streams
2 |
3 | This package implements the [ActivityStreams 2.0](https://www.w3.org/TR/activitystreams-core/)
4 | specifications in Hannibal. Specifically, it provides an overly simplistic view of JSON-LD
5 | documents, contexts, and the various types of ActivityStream collections.
6 |
7 | This is not a rigorous implementation of JSON-LD. Instead, it is an easy way to navigate
8 | well-formatted JSON-LD documents and to iterate over their contents, as well as loading
9 | additional documents from the web when necessary.
10 |
11 | ```go
12 | // Load a document directly from its URL
13 | document, err := streams.Load("https://your.activitypub.server/@documentId")
14 |
15 | document.ID() // returns a string value
16 | document.Content() // returns the content property
17 | document.Published() // returns a time value
18 |
19 | // AttributedTo could be many things.. a single value, a link, or
20 | // an array of values and links. Let's make all of that easier.
21 | authors := document.AttributedTo()
22 |
23 | // You could just read the first author directly
24 | authors.Name() // returns the 'Name' string
25 | authors.ID() // returns the 'ID' string
26 |
27 | // Or you can use it as an iterator
28 | for authors := document.AttributedTo ; !authors.IsNil() ; authors = authors.Tail() {
29 | authors.Value() // returns the whole value from the array
30 | }
31 | ```
--------------------------------------------------------------------------------
/streams/bluemonday_test.go:
--------------------------------------------------------------------------------
1 | package streams
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | // TestBlueMonday tests the content filtering implementation in streams.Documents
10 | func TestBlueMonday(t *testing.T) {
11 |
12 | badValue := map[string]any{
13 | "name": "John Connor",
14 | "summary": "This is a bad summary ",
15 | "content": "
Some of this content should be visible.
", 16 | } 17 | 18 | badDocument := NewDocument(badValue) 19 | 20 | require.Equal(t, "John Connor", badDocument.Name()) 21 | require.Equal(t, "This is a bad summary ", badDocument.Summary()) 22 | require.Equal(t, "Some of this content should be visible.
", badDocument.Content()) 23 | } 24 | -------------------------------------------------------------------------------- /streams/client.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | // Client represents an HTTP client (or facades in front of one) 4 | // that can load a JSON-LD document from a remote server. A Client 5 | // is injected into each streams.Document record so that the 6 | // Document can load additional linked data as needed. 7 | type Client interface { 8 | 9 | // SetRootClient is used to make a pointer to the top-level 10 | // client. This may be needed by some stacked clients that 11 | // make recursive calls to the Interwebs. 12 | SetRootClient(Client) 13 | 14 | // Load returns a Document representing the specified URI. 15 | Load(uri string, options ...any) (Document, error) 16 | 17 | // Save stores the Document in a local cache. (NOOP for most clients) 18 | Save(document Document) error 19 | 20 | // Delete removes a Document from a local cache (NOOP for most clients) 21 | Delete(documentID string) error 22 | } 23 | -------------------------------------------------------------------------------- /streams/client_http.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "github.com/benpate/derp" 5 | "github.com/benpate/hannibal/vocab" 6 | "github.com/benpate/remote" 7 | ) 8 | 9 | type DefaultClient struct{} 10 | 11 | func NewDefaultClient() Client { 12 | return DefaultClient{} 13 | } 14 | 15 | func (client DefaultClient) SetRootClient(rootClient Client) {} 16 | 17 | // Load implements the hannibal.Client interface, which loads an ActivityStream 18 | // document from a remote server. For the hannibal default client, this method 19 | // simply loads the document from a remote server with no other processing. 20 | func (client DefaultClient) Load(url string, options ...any) (Document, error) { 21 | 22 | const location = "hannibal.streams.Client.Load" 23 | 24 | result := make(map[string]any) 25 | 26 | // Try to load-and-parse the value from the remote server 27 | transaction := remote.Get(url). 28 | Accept(vocab.ContentTypeActivityPub). 29 | Result(&result) 30 | 31 | if err := transaction.Send(); err != nil { 32 | return NilDocument(), derp.Wrap(err, location, "Error loading JSON-LD document", url) 33 | } 34 | 35 | // Return in triumph 36 | return NewDocument(result, 37 | WithClient(client), 38 | WithHTTPHeader(transaction.ResponseHeader()), 39 | ), 40 | nil 41 | } 42 | 43 | // Save is required to implement the document.Cache interface. 44 | // For this client, Save is a NOOP 45 | func (client DefaultClient) Save(document Document) error { 46 | return nil 47 | } 48 | 49 | // Delete is required to implement the document.Cache interface. 50 | // For this client, Delete is a NOOP 51 | func (client DefaultClient) Delete(documentID string) error { 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /streams/client_test.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/benpate/rosetta/mapof" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | // nolint:unused 12 | type testClient struct { 13 | data mapof.Any 14 | } 15 | 16 | // nolint:unused 17 | func (client testClient) SetRootClient(rootClient Client) {} 18 | 19 | // nolint:unused 20 | func (client testClient) Load(uri string, options ...any) (Document, error) { 21 | 22 | if value, ok := client.data[uri]; ok { 23 | return NewDocument(value, WithClient(client)), nil 24 | } 25 | 26 | return NilDocument(), derp.InternalError("hannibal.streams.testClient.Load", "Unknown URI", uri) 27 | } 28 | 29 | func (client testClient) Save(document Document) error { 30 | return nil 31 | } 32 | 33 | func (client testClient) Delete(documentID string) error { 34 | return nil 35 | } 36 | 37 | func TestTestClient(t *testing.T) { 38 | 39 | // this is just a hack to make the "unused" linting messages go away 40 | client := testClient{} 41 | 42 | document := NewDocument(nil, WithClient(client)) 43 | 44 | require.Nil(t, client.Save(document)) 45 | require.Nil(t, client.Delete(document.ID())) 46 | } 47 | -------------------------------------------------------------------------------- /streams/collection.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/benpate/hannibal/vocab" 8 | "github.com/benpate/rosetta/mapof" 9 | ) 10 | 11 | // Collection is a subtype of Object that represents ordered or unordered sets of Object or Link instances. 12 | // https://www.w3.org/ns/activitystreams#Collection 13 | type Collection struct { 14 | Context Context `json:"@context,omitempty" bson:"context,omitempty"` 15 | ID string `json:"id,omitempty" bson:"id,omitempty"` 16 | Type string `json:"type,omitempty" bson:"type,omitempty"` 17 | Summary string `json:"summary,omitempty" bson:"summary,omitempty"` // A natural language summarization of the object encoded as HTML. Multiple language tagged summaries may be provided. 18 | TotalItems int `json:"totalItems,omitempty" bson:"totalItems,omitempty"` // A non-negative integer specifying the total number of objects contained by the logical view of the collection. This number might not reflect the actual number of items serialized within the Collection object instance. 19 | Current string `json:"current,omitempty" bson:"current,omitempty"` // In a paged Collection, indicates the page that contains the most recently updated member items. 20 | First string `json:"first,omitempty" bson:"first,omitempty"` // In a paged Collection, indicates the furthest preceding page of items in the collection. 21 | Last string `json:"last,omitempty" bson:"last,omitempty"` // In a paged Collection, indicates the furthest proceeding page of the collection. 22 | Items []any `json:"items,omitempty" bson:"items,omitempty"` // Identifies the items contained in a collection. The items might be ordered or unordered. 23 | } 24 | 25 | func NewCollection(collectionID string) Collection { 26 | return Collection{ 27 | Context: DefaultContext(), 28 | Type: vocab.CoreTypeCollection, 29 | ID: collectionID, 30 | } 31 | } 32 | 33 | /****************************************** 34 | * JSON Marshalling 35 | ******************************************/ 36 | 37 | func (c *Collection) UnmarshalJSON(data []byte) error { 38 | 39 | result := make(map[string]any) 40 | 41 | if err := json.Unmarshal(data, &result); err != nil { 42 | return derp.Wrap(err, "activitystreams.Collection.UnmarshalJSON", "Error unmarshalling JSON", string(data)) 43 | } 44 | 45 | return c.UnmarshalMap(result) 46 | } 47 | 48 | func (c *Collection) UnmarshalMap(data mapof.Any) error { 49 | 50 | if dataType := data.GetString("type"); dataType != vocab.CoreTypeCollection { 51 | return derp.InternalError("activitystreams.Collection.UnmarshalMap", "Invalid type", dataType) 52 | } 53 | 54 | c.Type = vocab.CoreTypeCollection 55 | c.Summary = data.GetString("summary") 56 | c.TotalItems = data.GetInt("totalItems") 57 | c.Current = data.GetString("current") 58 | c.First = data.GetString("first") 59 | c.Last = data.GetString("last") 60 | 61 | if dataItems, ok := data["items"]; ok { 62 | if items, ok := UnmarshalItems(dataItems); ok { 63 | c.Items = items 64 | } 65 | } 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /streams/collectionPage.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/benpate/hannibal/vocab" 8 | "github.com/benpate/rosetta/mapof" 9 | ) 10 | 11 | // CollectionPage is used to represent distinct subsets of items from a Collection. Refer to the Activity Streams 2.0 Core for a complete description of the CollectionPage object. 12 | // https://www.w3.org/ns/activitystreams#CollectionPage 13 | type CollectionPage struct { 14 | Context Context `json:"@context,omitempty" bson:"context,omitempty"` 15 | Type string `json:"type,omitempty" bson:"type,omitempty"` // Identifies the Object or Link type. (CollectionPage, OrderedCollectionPage) 16 | ID string `json:"id,omitempty" bson:"id,omitempty"` // Provides the globally unique identifier for an Object or Link. 17 | Summary string `json:"summary,omitempty" bson:"summary,omitempty"` // A natural language summarization of the object encoded as HTML. Multiple language tagged summaries may be provided. 18 | TotalItems int `json:"totalItems,omitempty" bson:"totalItems,omitempty"` // A non-negative integer specifying the total number of objects contained by the logical view of the collection. This number might not reflect the actual number of items serialized within the Collection object instance. 19 | Current string `json:"current,omitempty" bson:"current,omitempty"` // In a paged Collection, indicates the page that contains the most recently updated member items. 20 | First string `json:"first,omitempty" bson:"first,omitempty"` // In a paged Collection, indicates the furthest preceding page of items in the collection. 21 | Last string `json:"last,omitempty" bson:"last,omitempty"` // In a paged Collection, indicates the furthest proceeding page of the collection. 22 | PartOf string `json:"partOf,omitempty" bson:"partOf,omitempty"` // dentifies the Collection to which a CollectionPage objects items belong. 23 | Prev string `json:"prev,omitempty" bson:"prev,omitempty"` // In a paged Collection, identifies the previous page of items. 24 | Next string `json:"next,omitempty" bson:"next,omitempty"` // In a paged Collection, indicates the next page of items. 25 | Items []any `json:"items,omitempty" bson:"items,omitempty"` // Identifies the items contained in a collection. The items might be ordered or unordered. 26 | } 27 | 28 | func NewCollectionPage(pageID string) CollectionPage { 29 | return CollectionPage{ 30 | Context: DefaultContext(), 31 | ID: pageID, 32 | Type: vocab.CoreTypeCollectionPage, 33 | } 34 | } 35 | 36 | /****************************************** 37 | * JSON Marshalling 38 | ******************************************/ 39 | 40 | func (c *CollectionPage) UnmarshalJSON(data []byte) error { 41 | 42 | result := mapof.NewAny() 43 | 44 | if err := json.Unmarshal(data, &result); err != nil { 45 | return derp.Wrap(err, "activitystreams.CollectionPage.UnmarshalJSON", "Error unmarshalling JSON", string(data)) 46 | } 47 | 48 | return c.UnmarshalMap(result) 49 | } 50 | 51 | func (c *CollectionPage) UnmarshalMap(data mapof.Any) error { 52 | 53 | if dataType := data.GetString("type"); dataType != vocab.CoreTypeCollectionPage { 54 | return derp.InternalError("activitystreams.CollectionPage.UnmarshalMap", "Invalid type", dataType) 55 | } 56 | 57 | c.Type = vocab.CoreTypeCollectionPage 58 | c.ID = data.GetString("id") 59 | c.Summary = data.GetString("summary") 60 | c.TotalItems = data.GetInt("totalItems") 61 | c.Current = data.GetString("current") 62 | c.First = data.GetString("first") 63 | c.Last = data.GetString("last") 64 | c.PartOf = data.GetString("partOf") 65 | c.Prev = data.GetString("prev") 66 | c.Next = data.GetString("next") 67 | 68 | if dataItems, ok := data["items"]; ok { 69 | if items, ok := UnmarshalItems(dataItems); ok { 70 | c.Items = items 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /streams/context.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | 7 | "github.com/benpate/derp" 8 | "github.com/benpate/hannibal/vocab" 9 | ) 10 | 11 | type Context []ContextEntry 12 | 13 | func NewContext(args ...string) Context { 14 | result := make(Context, len(args)) 15 | 16 | for index, arg := range args { 17 | result[index] = NewContextEntry(arg) 18 | } 19 | 20 | return result 21 | } 22 | 23 | // DefaultContext represents the standard context defined by the W3C 24 | func DefaultContext() Context { 25 | return NewContext(vocab.NamespaceActivityStreams) 26 | } 27 | 28 | func (c Context) Length() int { 29 | if c == nil { 30 | return 0 31 | } 32 | 33 | return len(c) 34 | } 35 | 36 | func (c Context) IsEmpty() bool { 37 | return c.Length() == 0 38 | } 39 | 40 | func (c Context) IsEmptyTail() bool { 41 | return c.Length() <= 1 42 | } 43 | 44 | func (c Context) Head() *ContextEntry { 45 | if c.Length() == 0 { 46 | return nil 47 | } 48 | 49 | return &(c[0]) 50 | } 51 | 52 | func (c Context) Tail() Context { 53 | if c.Length() == 0 { 54 | return c 55 | } 56 | 57 | return c[1:] 58 | } 59 | 60 | // Add puts a new ContextEntry into the list and 61 | // returns a pointer to it so that additional properties 62 | // can be set. 63 | func (c *Context) Add(vocabulary string) *ContextEntry { 64 | entry := NewContextEntry(vocabulary) 65 | *c = append(*c, entry) 66 | return &((*c)[len(*c)-1]) 67 | } 68 | 69 | func (c Context) MarshalJSON() ([]byte, error) { 70 | 71 | const location = "writer.Context.MarshalJSON" 72 | 73 | switch len(c) { 74 | 75 | case 0: 76 | return []byte("null"), nil 77 | 78 | case 1: 79 | return json.Marshal(c[0]) 80 | } 81 | 82 | // Otherwise, write the Context as an array 83 | var buffer bytes.Buffer 84 | 85 | buffer.WriteByte('[') 86 | 87 | for index, context := range c { 88 | if index > 0 { 89 | buffer.WriteByte(',') 90 | } 91 | 92 | item, err := json.Marshal(context) 93 | 94 | if err != nil { 95 | return nil, derp.Wrap(err, location, "Failed to marshal context") 96 | } 97 | 98 | buffer.Write(item) 99 | } 100 | 101 | buffer.WriteByte(']') 102 | 103 | return buffer.Bytes(), nil 104 | } 105 | 106 | func (c *Context) UnmarshalJSON(data []byte) error { 107 | 108 | const location = "writer.Context.UnmarshalJSON" 109 | 110 | // If the data is empty, then this object is empty, too 111 | if len(data) == 0 { 112 | *c = make(Context, 0) 113 | return nil 114 | } 115 | 116 | // If this looks like a single item, then unmarshal it as a single item 117 | if (data[0] == '{') || (data[0] == '"') { 118 | 119 | onlyContext := ContextEntry{} 120 | 121 | if err := json.Unmarshal(data, &onlyContext); err != nil { 122 | return derp.Wrap(err, location, "Failed to unmarshal context") 123 | } 124 | 125 | *c = Context{onlyContext} 126 | return nil 127 | } 128 | 129 | // Otherwise, this looks like an array of contexts 130 | var entries []ContextEntry 131 | 132 | if err := json.Unmarshal(data, &entries); err != nil { 133 | return derp.Wrap(err, location, "Failed to unmarshal context array") 134 | } 135 | 136 | *c = entries 137 | return nil 138 | } 139 | -------------------------------------------------------------------------------- /streams/contextEntry.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // ContextEntry 8 | // https://www.w3.org/TR/json-ld/#the-context 9 | type ContextEntry struct { 10 | Vocabulary string // The primary vocabulary represented by the context/document. 11 | Language string // The language 12 | Extensions map[string]string // a map of additional namespaces that are included in this context/document. 13 | } 14 | 15 | func NewContextEntry(vocabulary string) ContextEntry { 16 | return ContextEntry{ 17 | Vocabulary: vocabulary, 18 | Language: "und", 19 | Extensions: make(map[string]string), 20 | } 21 | } 22 | 23 | func (entry *ContextEntry) WithLanguage(language string) *ContextEntry { 24 | entry.Language = language 25 | return entry 26 | } 27 | 28 | func (entry *ContextEntry) WithExtension(key string, value string) *ContextEntry { 29 | if len(entry.Extensions) == 0 { 30 | entry.Extensions = make(map[string]string) 31 | } 32 | 33 | entry.Extensions[key] = value 34 | return entry 35 | } 36 | 37 | func (entry ContextEntry) MarshalJSON() ([]byte, error) { 38 | 39 | // If this context only has a vocabulary, then 40 | // use the short-form "string only" syntax 41 | if entry.IsVocabularyOnly() { 42 | return json.Marshal(entry.Vocabulary) 43 | } 44 | 45 | // Otherwise, use the long-form syntax as a JSON object 46 | result := make(map[string]any) 47 | result["@vocab"] = entry.Vocabulary 48 | 49 | if entry.IsLanguageDefined() { 50 | result["@language"] = entry.Language 51 | } 52 | 53 | if entry.HasExtensions() { 54 | for key, value := range entry.Extensions { 55 | result[key] = value 56 | } 57 | } 58 | 59 | return json.Marshal(result) 60 | } 61 | 62 | func (entry ContextEntry) IsVocabularyOnly() bool { 63 | if entry.IsLanguageDefined() { 64 | return false 65 | } 66 | 67 | if entry.HasExtensions() { 68 | return false 69 | } 70 | 71 | return true 72 | } 73 | 74 | func (entry ContextEntry) IsLanguageDefined() bool { 75 | if entry.Language == "" { 76 | return false 77 | } 78 | 79 | if entry.Language == "und" { 80 | return false 81 | } 82 | 83 | return true 84 | } 85 | 86 | func (entry ContextEntry) HasExtensions() bool { 87 | return len(entry.Extensions) > 0 88 | } 89 | -------------------------------------------------------------------------------- /streams/context_test.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestNew(t *testing.T) { 11 | 12 | // Test default context data 13 | { 14 | c := DefaultContext() 15 | 16 | require.True(t, c.IsEmptyTail()) 17 | require.True(t, c.Tail().IsEmpty()) 18 | head := c.Head() 19 | 20 | if head == nil { 21 | t.Fatal("Head() returned nil") 22 | return 23 | } 24 | 25 | require.Equal(t, "https://www.w3.org/ns/activitystreams", head.Vocabulary) 26 | require.Equal(t, "und", head.Language) 27 | require.Zero(t, len(head.Extensions)) 28 | 29 | result, err := c.MarshalJSON() 30 | require.Nil(t, err) 31 | require.Equal(t, `"https://www.w3.org/ns/activitystreams"`, string(result)) 32 | } 33 | 34 | // Test custom context, and chaining multiple contexts 35 | { 36 | c := NewContext() 37 | entry := c.Add("https://test.com").WithLanguage("en-us") 38 | head := c.Head() 39 | 40 | if head == nil { 41 | t.Fatal("Head() returned nil") 42 | return 43 | } 44 | 45 | // Verify the head entry 46 | require.Equal(t, "https://test.com", head.Vocabulary) 47 | require.Equal(t, "en-us", head.Language) 48 | require.Zero(t, len(head.Extensions)) 49 | 50 | { 51 | result, err := json.Marshal(c) 52 | require.Nil(t, err) 53 | require.Equal(t, `{"@language":"en-us","@vocab":"https://test.com"}`, string(result)) 54 | } 55 | 56 | entry.WithExtension("ext", "https://extension.com/ns/activitystreams") 57 | 58 | json1, err1 := c.MarshalJSON() 59 | require.Nil(t, err1) 60 | require.Equal(t, `{"@language":"en-us","@vocab":"https://test.com","ext":"https://extension.com/ns/activitystreams"}`, string(json1)) 61 | 62 | c.Add("https://www.w3.org/ns/activitystreams") 63 | json2, err2 := c.MarshalJSON() 64 | 65 | require.Equal(t, `[{"@language":"en-us","@vocab":"https://test.com","ext":"https://extension.com/ns/activitystreams"},"https://www.w3.org/ns/activitystreams"]`, string(json2)) 66 | require.Nil(t, err2) 67 | } 68 | 69 | // Test safely adding an extension to an improperly initialized context 70 | { 71 | c := NewContext() 72 | c.Add("https://test.com"). 73 | WithExtension("dog", "https://dog.com/ns/activitystreams") 74 | 75 | head := c.Head() 76 | 77 | if head == nil { 78 | t.Fatal("Head() returned nil") 79 | return 80 | } 81 | 82 | require.Equal(t, "https://test.com", head.Vocabulary) 83 | require.Equal(t, "und", head.Language) 84 | require.Equal(t, head.Extensions["dog"], "https://dog.com/ns/activitystreams") 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /streams/documentOption.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import "net/http" 4 | 5 | type DocumentOption func(*Document) 6 | 7 | // WithClient option sets the HTTP client that can load remote documents if necessary 8 | func WithClient(client Client) DocumentOption { 9 | return func(doc *Document) { 10 | if client == nil { 11 | doc.client = NewDefaultClient() 12 | } else { 13 | doc.client = client 14 | } 15 | } 16 | } 17 | 18 | // WithHTTPHeader attaches an HTTP header to the document 19 | func WithHTTPHeader(httpHeader http.Header) DocumentOption { 20 | return func(doc *Document) { 21 | doc.httpHeader = httpHeader 22 | } 23 | } 24 | 25 | // WithMetadata attaches metadata to the document 26 | func WithMetadata(metadata Metadata) DocumentOption { 27 | return func(doc *Document) { 28 | doc.Metadata = metadata 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /streams/document_actor.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "github.com/benpate/domain" 5 | "github.com/benpate/hannibal/vocab" 6 | ) 7 | 8 | // https://www.w3.org/TR/activitypub/#x4-1-actor-objects 9 | 10 | // https://www.w3.org/TR/activitypub/#inbox 11 | func (document Document) Inbox() Document { 12 | return document.Get(vocab.PropertyInbox) 13 | } 14 | 15 | // https://www.w3.org/TR/activitypub/#outbox 16 | func (document Document) Outbox() Document { 17 | return document.Get(vocab.PropertyOutbox) 18 | } 19 | 20 | // https://www.w3.org/TR/activitypub/#following 21 | func (document Document) Following() Document { 22 | return document.Get(vocab.PropertyFollowing) 23 | } 24 | 25 | // https://www.w3.org/TR/activitypub/#followers 26 | func (document Document) Followers() Document { 27 | return document.Get(vocab.PropertyFollowers) 28 | } 29 | 30 | // https://www.w3.org/TR/activitypub/#liked 31 | func (document Document) Liked() Document { 32 | return document.Get(vocab.PropertyLiked) 33 | } 34 | 35 | // https://www.w3.org/TR/activitypub/#likes 36 | func (document Document) Likes() Document { 37 | return document.Get(vocab.PropertyLikes) 38 | } 39 | 40 | // http://w3id.org/fep/c648 41 | func (document Document) Blocked() Document { 42 | return document.Get(vocab.PropertyBlocked) 43 | } 44 | 45 | // https://www.w3.org/TR/activitypub/#streams-property 46 | func (document Document) Streams() Document { 47 | return document.Get(vocab.PropertyStreams) 48 | } 49 | 50 | // https://www.w3.org/TR/activitypub/#preferredUsername 51 | func (document Document) PreferredUsername() string { 52 | return document.Get(vocab.PropertyPreferredUsername).String() 53 | } 54 | 55 | // Alias for https://www.w3.org/TR/activitypub/#preferredUsername 56 | func (document Document) Username() string { 57 | return document.PreferredUsername() 58 | } 59 | 60 | // https://docs.joinmastodon.org/spec/activitypub/#featured 61 | func (document Document) Featured() Document { 62 | return document.Get(vocab.PropertyFeatured) 63 | } 64 | 65 | // UsernameOrID returns the username of the document, if it exists, or the ID of the document if it does not. 66 | func (document Document) UsernameOrID() string { 67 | if username := document.PreferredUsername(); username != "" { 68 | return "@" + username + "@" + domain.NameOnly(document.ID()) 69 | } 70 | return document.ID() 71 | } 72 | 73 | // https://www.w3.org/TR/activitypub/#endpoints 74 | func (document Document) Endpoints() Document { 75 | return document.Get(vocab.PropertyEndpoints) 76 | } 77 | -------------------------------------------------------------------------------- /streams/document_header.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import "net/http" 4 | 5 | // HTTPHeader returns the http.Header object associated with this document 6 | func (document Document) HTTPHeader() http.Header { 7 | return document.httpHeader 8 | } 9 | 10 | // SetHTTPHeader sets the http.Header object associated with this document 11 | func (document *Document) SetHTTPHeader(httpHeader http.Header) { 12 | document.httpHeader = httpHeader 13 | } 14 | -------------------------------------------------------------------------------- /streams/document_json.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/benpate/hannibal/property" 8 | ) 9 | 10 | /****************************************** 11 | * Custom JSON Marshalling / Unmarshalling 12 | ******************************************/ 13 | 14 | // MarshalJSON implements the json.Marshaller interface, 15 | // and provides a custom marshalling into JSON -- 16 | // essentially just aiming the marshaller at the 17 | // Document's value. 18 | func (document Document) MarshalJSON() ([]byte, error) { 19 | return json.Marshal(document.value.Raw()) 20 | } 21 | 22 | // UnmarshalJSON implements the json.Unmarshaller interface, 23 | // and provides a custom un-marshalling from JSON -- 24 | // essentially just aiming the unmashaller at the 25 | // Document's value 26 | func (document *Document) UnmarshalJSON(bytes []byte) error { 27 | value := document.value.Raw() 28 | if err := json.Unmarshal(bytes, &value); err != nil { 29 | return derp.Wrap(err, "streams.Document.UnmarshalJSON", "Error unmarshalling JSON into Document") 30 | } 31 | 32 | document.value = property.NewValue(value) 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /streams/document_set.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "github.com/benpate/hannibal/vocab" 5 | "github.com/benpate/rosetta/convert" 6 | ) 7 | 8 | // SetString sets a string property on the document 9 | func (document *Document) SetString(name string, value ...string) bool { 10 | 11 | head := document.value.Head() 12 | 13 | if len(value) == 1 { 14 | document.value = head.Set(name, value[0]) 15 | return true 16 | } 17 | 18 | document.value = head.Set(name, value) 19 | return true 20 | } 21 | 22 | // Append appends a value to a property on the document 23 | func (document *Document) Append(name string, value any) bool { 24 | 25 | // RULE: If the value is empty, then NOOP 26 | if value == "" { 27 | return false 28 | } 29 | 30 | currentValue := convert.SliceOfAny(document.value.Head().Get(name)) 31 | newValue := append(currentValue, value) 32 | 33 | document.value.Set(name, newValue) 34 | return true 35 | } 36 | 37 | // AppendString appends a string to a property on the document 38 | func (document *Document) AppendString(name string, value string) bool { 39 | 40 | // RULE: If the value is empty, then NOOP 41 | if value == "" { 42 | return false 43 | } 44 | 45 | currentValue := convert.SliceOfAny(document.value.Head().Get(name)) 46 | newValue := append(currentValue, value) 47 | 48 | document.value.Set(name, newValue) 49 | return true 50 | } 51 | 52 | /****************************************** 53 | * Set/Append Helpers 54 | ******************************************/ 55 | 56 | // SetTo sets the "to" property of the document 57 | func (document *Document) SetTo(value ...string) bool { 58 | return document.SetString(vocab.PropertyTo, value...) 59 | } 60 | 61 | // SetBto sets the "bto" property of the document 62 | func (document *Document) SetBTo(value ...string) bool { 63 | return document.SetString(vocab.PropertyBTo, value...) 64 | } 65 | 66 | // SetCC sets the "cc" property of the document 67 | func (document *Document) SetCC(value ...string) bool { 68 | return document.SetString(vocab.PropertyCC, value...) 69 | } 70 | 71 | // SetBCC sets the "bcc" property of the document 72 | func (document *Document) SetBCC(value ...string) bool { 73 | return document.SetString(vocab.PropertyBCC, value...) 74 | } 75 | 76 | // AppendTo appends a value to the "to" property of the document 77 | func (document *Document) AppendTo(value string) bool { 78 | return document.AppendString(vocab.PropertyTo, value) 79 | } 80 | 81 | // AppendBTo appends a value to the "bto" property of the document 82 | func (document *Document) AppendBTo(value string) bool { 83 | return document.AppendString(vocab.PropertyBTo, value) 84 | } 85 | 86 | // AppendCC appends a value to the "cc" property of the document 87 | func (document *Document) AppendCC(value string) bool { 88 | return document.AppendString(vocab.PropertyCC, value) 89 | } 90 | 91 | // AppendBCC appends a value to the "bcc" property of the document 92 | func (document *Document) AppendBCC(value string) bool { 93 | return document.AppendString(vocab.PropertyBCC, value) 94 | } 95 | -------------------------------------------------------------------------------- /streams/document_special.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "strconv" 5 | ) 6 | 7 | /****************************************** 8 | * Type Detection 9 | ******************************************/ 10 | 11 | func (document Document) DocumentCategory() string { 12 | return DocumentCategory(document.Type()) 13 | } 14 | 15 | // IsActivity returns TRUE if this document represents an Activity 16 | func (document Document) IsActivity() bool { 17 | return IsActivity(document.Type()) 18 | } 19 | 20 | // NotActivity returns TRUE if this document does NOT represent an Activity 21 | func (document Document) NotActivity() bool { 22 | return !document.IsActivity() 23 | } 24 | 25 | // IsActor returns TRUE if this document represents an Actor 26 | func (document Document) IsActor() bool { 27 | return IsActor(document.Type()) 28 | } 29 | 30 | // NotActor returns TRUE if this document does NOT represent an Actor 31 | func (document Document) NotActor() bool { 32 | return !document.IsActor() 33 | } 34 | 35 | // IsCollection returns TRUE if this document represents a Collection or CollectionPage 36 | func (document Document) IsCollection() bool { 37 | return IsCollection(document.Type()) 38 | } 39 | 40 | // NotCollection returns TRUE if the document does NOT represent a Collection or CollectionPage 41 | func (document Document) NotCollection() bool { 42 | return !document.IsCollection() 43 | } 44 | 45 | // IsObject returns TRUE if this document represents an Object type (Article, Note, etc) 46 | func (document Document) IsObject() bool { 47 | return IsObject(document.Type()) 48 | } 49 | 50 | // NotObject returns TRUE if this document does NOT represent an Object type (Article, Note, etc) 51 | func (document Document) NotObject() bool { 52 | return !document.IsObject() 53 | } 54 | 55 | // HasIcon returns TRUE if this document has a valid Icon property 56 | func (document Document) HasIcon() bool { 57 | return document.Icon().NotNil() 58 | } 59 | 60 | // HasImage returns TRUE if this document has a valid Image property 61 | func (document Document) HasImage() bool { 62 | return document.Image().NotNil() 63 | } 64 | 65 | // HasContent returns TRUE if this document has a valid Content property 66 | func (document Document) HasContent() bool { 67 | return document.Content() != "" 68 | } 69 | 70 | // HasSummary returns TRUE if this document has a valid Summary property 71 | func (document Document) HasSummary() bool { 72 | return document.Summary() != "" 73 | } 74 | 75 | func (document Document) HasDimensions() bool { 76 | return document.Width() > 0 && document.Height() > 0 77 | } 78 | 79 | func (document Document) SummaryWithTagLinks() string { 80 | 81 | summary := document.Summary() 82 | 83 | if summary == "" { 84 | return "" 85 | } 86 | 87 | for tag := range document.Tag().Range() { 88 | href := tag.Href() 89 | 90 | if href == "" { 91 | continue 92 | } 93 | 94 | tagName := tag.Name() 95 | tagNameLength := len(tagName) 96 | 97 | if tagNameLength == 0 { 98 | continue 99 | } 100 | 101 | startPosition := 0 102 | for { 103 | 104 | index := indexOfNoCase(summary, tagName, startPosition) 105 | 106 | if index < 0 { 107 | break 108 | } 109 | 110 | tagLink := `` + tagName + `` 111 | tagLinkLength := len(tagLink) 112 | 113 | summary = summary[:index] + tagLink + summary[index+tagNameLength:] 114 | 115 | startPosition = index + tagLinkLength 116 | } 117 | } 118 | 119 | return summary 120 | } 121 | 122 | func (document Document) AspectRatio() string { 123 | 124 | width := document.Width() 125 | height := document.Height() 126 | 127 | if width == 0 || height == 0 { 128 | return "auto" 129 | } 130 | 131 | ratio := float64(width) / float64(height) 132 | return strconv.FormatFloat(ratio, 'f', -1, 64) 133 | } 134 | 135 | // If this document is an activity (create, update, delete, etc), then 136 | // this method returns the activity's Object. Otherwise, it returns 137 | // the document itself. 138 | func (document Document) UnwrapActivity() Document { 139 | 140 | // If this is an "Activity" type, the dig deeper into the object 141 | // to find the actual document. 142 | // This is recursive because it's possible to have a deep tree 143 | // such as Announce > Create > Document. Looking at you, Lemmy... 144 | if document.IsActivity() { 145 | return document.Object().UnwrapActivity() 146 | } 147 | 148 | return document 149 | } 150 | -------------------------------------------------------------------------------- /streams/document_test.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/benpate/rosetta/mapof" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestDocument(t *testing.T) { 11 | 12 | d := NewDocument(map[string]any{ 13 | "id": "https://example.com", 14 | }) 15 | 16 | require.Equal(t, "https://example.com", d.ID()) 17 | } 18 | 19 | func TestDocumentMapOfAny(t *testing.T) { 20 | 21 | d := NewDocument(mapof.Any{ 22 | "id": "https://example.com", 23 | }) 24 | 25 | require.True(t, d.IsMap()) 26 | require.Equal(t, "https://example.com", d.ID()) 27 | } 28 | -------------------------------------------------------------------------------- /streams/image.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "github.com/benpate/hannibal/property" 5 | "github.com/benpate/hannibal/vocab" 6 | "github.com/benpate/rosetta/convert" 7 | "github.com/benpate/rosetta/mapof" 8 | "github.com/benpate/rosetta/sliceof" 9 | ) 10 | 11 | // https://www.w3.org/ns/activitystreams#Image 12 | type Image struct { 13 | value any 14 | } 15 | 16 | // NewImage creates a new Image object from a JSON-LD value (string, map[string]any, or []any) 17 | func NewImage(value any) Image { 18 | 19 | // NILCHECK: allow `nil` values to become empty images 20 | if value == nil { 21 | return Image{} 22 | } 23 | 24 | switch typed := value.(type) { 25 | 26 | case Document: 27 | return NewImage(typed.value.Raw()) 28 | 29 | case property.Value: 30 | return NewImage(typed.Raw()) 31 | 32 | case Image: 33 | return typed 34 | 35 | case string: 36 | return Image{value: typed} 37 | 38 | case map[string]any: 39 | return Image{value: typed} 40 | 41 | case []any: 42 | return Image{value: typed} 43 | 44 | case mapof.Any: 45 | return Image{value: map[string]any(typed)} 46 | 47 | case sliceof.Any: 48 | return Image{value: []any(typed)} 49 | } 50 | 51 | return Image{""} 52 | } 53 | 54 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-href 55 | // Note: URL is an alias for Href, which is the proper name to use 56 | func (image Image) URL() string { 57 | return image.Href() 58 | } 59 | 60 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-href 61 | // Note: This method searches both the "href" and "url" properties in maps. 62 | func (image Image) Href() string { 63 | 64 | switch typed := image.value.(type) { 65 | 66 | case string: 67 | return typed 68 | 69 | case map[string]any: 70 | 71 | if href := convert.String(typed[vocab.PropertyHref]); href != "" { 72 | return href 73 | } 74 | 75 | if url := convert.String(typed[vocab.PropertyURL]); url != "" { 76 | 77 | return url 78 | } 79 | 80 | case []any: 81 | if len(typed) > 0 { 82 | return NewImage(typed[0]).URL() 83 | } 84 | } 85 | 86 | return "" 87 | } 88 | 89 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-summary 90 | func (image Image) Summary() string { 91 | 92 | switch typed := image.value.(type) { 93 | 94 | case map[string]any: 95 | return convert.String(typed[vocab.PropertySummary]) 96 | 97 | case []any: 98 | if len(typed) > 0 { 99 | return NewImage(typed[0]).Summary() 100 | } 101 | } 102 | 103 | return "" 104 | } 105 | 106 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-mediatype 107 | func (image Image) MediaType() string { 108 | 109 | switch typed := image.value.(type) { 110 | 111 | case map[string]any: 112 | return convert.String(typed[vocab.PropertyMediaType]) 113 | 114 | case []any: 115 | if len(typed) > 0 { 116 | return NewImage(typed[0]).MediaType() 117 | } 118 | } 119 | 120 | return "" 121 | } 122 | 123 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-height 124 | func (image Image) Height() int { 125 | 126 | switch typed := image.value.(type) { 127 | 128 | case map[string]any: 129 | return convert.Int(typed[vocab.PropertyHeight]) 130 | 131 | case []any: 132 | if len(typed) > 0 { 133 | return NewImage(typed[0]).Height() 134 | } 135 | } 136 | 137 | return 0 138 | } 139 | 140 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-width 141 | func (image Image) Width() int { 142 | 143 | switch typed := image.value.(type) { 144 | 145 | case map[string]any: 146 | return convert.Int(typed[vocab.PropertyWidth]) 147 | 148 | case []any: 149 | if len(typed) > 0 { 150 | return NewImage(typed[0]).Width() 151 | } 152 | } 153 | 154 | return 0 155 | } 156 | 157 | // IsNil returns TRUE if this image is nil (having no URL) 158 | func (image Image) IsNil() bool { 159 | return image.URL() == "" 160 | } 161 | 162 | // NotNil returns TRUE if this image has a URL 163 | func (image Image) NotNil() bool { 164 | return !image.IsNil() 165 | } 166 | 167 | // HasHeight returns TRUE if this image has a height defined 168 | func (image Image) HasHeight() bool { 169 | return image.Height() > 0 170 | } 171 | 172 | // HasWidth returns TRUE if this image has a width defined 173 | func (image Image) HasWidth() bool { 174 | return image.Width() > 0 175 | } 176 | 177 | // HasDimensions returns TRUE if this image has both a height and width defined 178 | func (image Image) HasDimensions() bool { 179 | return image.HasHeight() && image.HasWidth() 180 | } 181 | 182 | // AspectRatio calculates the aspect ratio of the image (width / height) 183 | // If height and width are not available, then 0 is returned 184 | func (image Image) AspectRatio() float64 { 185 | if image.HasDimensions() { 186 | return float64(image.Width()) / float64(image.Height()) 187 | } 188 | 189 | return 0 190 | } 191 | -------------------------------------------------------------------------------- /streams/items.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | func UnmarshalItems(data any) ([]any, bool) { 4 | result, ok := data.([]any) 5 | return result, ok 6 | } 7 | -------------------------------------------------------------------------------- /streams/metadata.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import "github.com/benpate/hannibal/vocab" 4 | 5 | // Metadata contains structured metadata for each document, which is useful for collecting/querying records in a database 6 | type Metadata struct { 7 | HashedID string `bson:"hashedId,omitempty"` // HashedID is a unique identifier for this document, used to prevent duplicate records 8 | DocumentCategory string `bson:"documentCategory,omitempty"` // High-level category of the document [Activity, Actor, Object, Collection] 9 | RelationType string `bson:"relationType,omitempty"` // If this document is related to another document, this contains the type of relation [Reply, Announce, Like, Dislike] 10 | RelationHref string `bson:"relationHref,omitempty"` // If this document is related to another document, this contains the URL of the related document 11 | Replies int64 `bson:"replies,omitempty"` // Replies is the number of replies to this document 12 | Announces int64 `bson:"announces,omitempty"` // Announces is the number of times this document has been announced / reposted 13 | Likes int64 `bson:"likes,omitempty"` // Likes is the number of times this document has been liked 14 | Dislikes int64 `bson:"dislikes,omitempty"` // Dislikes is the number of times this document has been disliked 15 | } 16 | 17 | // NewMetadata returns a fully initialized Metadata object 18 | func NewMetadata() Metadata { 19 | return Metadata{} 20 | } 21 | 22 | // IsActor returns TRUE if this document is one of several "Actor" types [Application, Group, Organization, Person, Service] 23 | func (metadata Metadata) IsActor() bool { 24 | return metadata.DocumentCategory == vocab.DocumentCategoryActor 25 | } 26 | 27 | // IsObject returns TRUE if this document is one of several "Object" types [Image, Video, Audio, Document, and others] 28 | func (metadata Metadata) IsObject() bool { 29 | return metadata.DocumentCategory == vocab.DocumentCategoryObject 30 | } 31 | 32 | // IsCollection returns TRUE if this document is one of several "Collection" types [Collection, CollectionPage, OrderedCollection, OrderedCollectionPage] 33 | func (metadata Metadata) IsCollection() bool { 34 | return metadata.DocumentCategory == vocab.DocumentCategoryCollection 35 | } 36 | 37 | // HasReplies returns TRUE if this document has one or more Replies 38 | func (metadata Metadata) HasReplies() bool { 39 | return metadata.Replies > 0 40 | } 41 | 42 | // HasAnnounces returns TRUE if this document has one or more Announces 43 | func (metadata Metadata) HasAnnounces() bool { 44 | return metadata.Announces > 0 45 | } 46 | 47 | // HasLikes returns TRUE if this document has one or more Likes 48 | func (metadata Metadata) HasLikes() bool { 49 | return metadata.Likes > 0 50 | } 51 | 52 | // HasDislikes returns TRUE if this document has one or more Dislikes 53 | func (metadata Metadata) HasDislikes() bool { 54 | return metadata.Dislikes > 0 55 | } 56 | 57 | // HasRelationship returns TRUE if this document has a relationship 58 | func (metadata Metadata) HasRelationship() bool { 59 | if metadata.RelationType == "" { 60 | return false 61 | } 62 | 63 | if metadata.RelationHref == "" { 64 | return false 65 | } 66 | 67 | return true 68 | } 69 | 70 | // SetRelationCount updates the designated relation with a new count, 71 | // returning TRUE if the value has been changed. 72 | func (metadata *Metadata) SetRelationCount(relationType string, count int64) bool { 73 | 74 | switch relationType { 75 | 76 | case vocab.RelationTypeReply: 77 | if metadata.Replies != count { 78 | metadata.Replies = count 79 | return true 80 | } 81 | 82 | case vocab.RelationTypeAnnounce: 83 | if metadata.Announces != count { 84 | metadata.Announces = count 85 | return true 86 | } 87 | 88 | case vocab.RelationTypeLike: 89 | if metadata.Likes != count { 90 | metadata.Likes = count 91 | return true 92 | } 93 | 94 | case vocab.RelationTypeDislike: 95 | if metadata.Dislikes != count { 96 | metadata.Dislikes = count 97 | return true 98 | } 99 | } 100 | 101 | return false 102 | } 103 | -------------------------------------------------------------------------------- /streams/options.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | // OptionStripContext instructs the Document.Map() method to remove the "@context" property from its ouput. 4 | const OptionStripContext = "STRIP_CONTEXT" 5 | 6 | // OptionStripRecipients instructs the Document.Map() method to remove all recipient properties from its output. 7 | // (To, BTo, CC, BCC) 8 | const OptionStripRecipients = "STRIP_RECCIPIENTS" 9 | -------------------------------------------------------------------------------- /streams/orderedCollection.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/benpate/hannibal/vocab" 8 | "github.com/benpate/rosetta/mapof" 9 | ) 10 | 11 | // OrderedCollection is a subtype of Collection in which members of the logical collection are assumed to always be strictly ordered. 12 | // https://www.w3.org/ns/activitystreams#OrderedCollection 13 | type OrderedCollection struct { 14 | Context Context `json:"@context,omitempty" bson:"@context,omitempty"` 15 | ID string `json:"id,omitempty" bson:"id,omitempty"` 16 | Type string `json:"type,omitempty" bson:"type,omitempty"` 17 | Summary string `json:"summary,omitempty" bson:"summary,omitempty"` // A natural language summarization of the object encoded as HTML. Multiple language tagged summaries may be provided. 18 | TotalItems int `json:"totalItems,omitempty" bson:"totalItems,omitempty"` // A non-negative integer specifying the total number of objects contained by the logical view of the collection. This number might not reflect the actual number of items serialized within the Collection object instance. 19 | OrderedItems []any `json:"orderedItems,omitempty" bson:"orderedItems,omitempty"` // Identifies the items contained in a collection. The items might be ordered or unordered. 20 | Current string `json:"current,omitempty" bson:"current,omitempty"` // In a paged Collection, indicates the page that contains the most recently updated member items. 21 | First string `json:"first,omitempty" bson:"first,omitempty"` // In a paged Collection, indicates the furthest preceding page of items in the collection. 22 | Last string `json:"last,omitempty" bson:"last,omitempty"` // In a paged Collection, indicates the furthest proceeding page of the collection. 23 | } 24 | 25 | func NewOrderedCollection(collectionID string) OrderedCollection { 26 | return OrderedCollection{ 27 | Context: DefaultContext(), 28 | Type: vocab.CoreTypeOrderedCollection, 29 | ID: collectionID, 30 | OrderedItems: make([]any, 0), 31 | } 32 | } 33 | 34 | func (c *OrderedCollection) UnmarshalJSON(data []byte) error { 35 | 36 | result := mapof.NewAny() 37 | 38 | if err := json.Unmarshal(data, &result); err != nil { 39 | return derp.Wrap(err, "activitystreams.OrderedCollection.UnmarshalJSON", "Error unmarshalling JSON", string(data)) 40 | } 41 | 42 | return c.UnmarshalMap(result) 43 | } 44 | 45 | func (c *OrderedCollection) UnmarshalMap(data mapof.Any) error { 46 | 47 | if dataType := data.GetString("type"); dataType != vocab.CoreTypeOrderedCollection { 48 | return derp.InternalError("activitystreams.OrderedCollection.UnmarshalMap", "Invalid type", dataType) 49 | } 50 | 51 | c.Type = vocab.CoreTypeOrderedCollection 52 | c.Summary = data.GetString("summary") 53 | c.TotalItems = data.GetInt("totalItems") 54 | c.Current = data.GetString("current") 55 | c.First = data.GetString("first") 56 | c.Last = data.GetString("last") 57 | 58 | if dataItems, ok := data["items"]; ok { 59 | if items, ok := UnmarshalItems(dataItems); ok { 60 | c.OrderedItems = items 61 | } 62 | } 63 | 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /streams/orderedCollectionPage.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/benpate/hannibal/vocab" 8 | "github.com/benpate/rosetta/mapof" 9 | ) 10 | 11 | // OrderedCollectionPage is used to represent ordered subsets of items from an OrderedCollection. Refer to the Activity Streams 2.0 Core for a complete description of the OrderedCollectionPage object. 12 | // https://www.w3.org/ns/activitystreams#OrderedCollectionPage 13 | type OrderedCollectionPage struct { 14 | Context Context `json:"@context,omitempty" bson:"context,omitempty"` 15 | Type string `json:"type,omitempty" bson:"type,omitempty"` 16 | ID string `json:"id,omitempty" bson:"id,omitempty"` // Provides the globally unique identifier for an Object or Link. 17 | Summary string `json:"summary,omitempty" bson:"summary,omitempty"` // A natural language summarization of the object encoded as HTML. Multiple language tagged summaries may be provided. 18 | TotalItems int `json:"totalItems,omitempty" bson:"totalItems,omitempty"` // A non-negative integer specifying the total number of objects contained by the logical view of the collection. This number might not reflect the actual number of items serialized within the Collection object instance. 19 | Current string `json:"current,omitempty" bson:"current,omitempty"` // In a paged Collection, indicates the page that contains the most recently updated member items. 20 | First string `json:"first,omitempty" bson:"first,omitempty"` // In a paged Collection, indicates the furthest preceding page of items in the collection. 21 | Last string `json:"last,omitempty" bson:"last,omitempty"` // In a paged Collection, indicates the furthest proceeding page of the collection. 22 | StartIndex int `json:"startIndex,omitempty" bson:"startIndex,omitempty"` // A non-negative integer value identifying the relative position within the logical view of a strictly ordered collection. 23 | PartOf string `json:"partOf,omitempty" bson:"partOf,omitempty"` // dentifies the Collection to which a CollectionPage objects items belong. 24 | Prev string `json:"prev,omitempty" bson:"prev,omitempty"` // In a paged Collection, identifies the previous page of items. 25 | Next string `json:"next,omitempty" bson:"next,omitempty"` // In a paged Collection, indicates the next page of items. 26 | OrderedItems []any `json:"orderedItems,omitempty" bson:"orderedItems,omitempty"` // Identifies the items contained in a collection. The items might be ordered or unordered. 27 | } 28 | 29 | func NewOrderedCollectionPage(pageID string, partOf string) OrderedCollectionPage { 30 | return OrderedCollectionPage{ 31 | Context: DefaultContext(), 32 | Type: vocab.CoreTypeOrderedCollectionPage, 33 | ID: pageID, 34 | PartOf: partOf, 35 | OrderedItems: make([]any, 0), 36 | } 37 | } 38 | 39 | func (c *OrderedCollectionPage) UnmarshalJSON(data []byte) error { 40 | 41 | result := mapof.NewAny() 42 | 43 | if err := json.Unmarshal(data, &result); err != nil { 44 | return derp.Wrap(err, "activitystreams.OrderedCollectionPage.UnmarshalJSON", "Error unmarshalling JSON", string(data)) 45 | } 46 | 47 | return c.UnmarshalMap(result) 48 | } 49 | 50 | func (c *OrderedCollectionPage) UnmarshalMap(data mapof.Any) error { 51 | 52 | if dataType := data.GetString("type"); dataType != vocab.CoreTypeOrderedCollectionPage { 53 | return derp.InternalError("activitystreams.OrderedCollectionPage.UnmarshalMap", "Invalid type", dataType) 54 | } 55 | 56 | c.Type = vocab.CoreTypeOrderedCollectionPage 57 | c.ID = data.GetString("id") 58 | c.Summary = data.GetString("summary") 59 | c.TotalItems = data.GetInt("totalItems") 60 | c.Current = data.GetString("current") 61 | c.First = data.GetString("first") 62 | c.Last = data.GetString("last") 63 | c.StartIndex = data.GetInt("startIndex") 64 | c.PartOf = data.GetString("partOf") 65 | c.Prev = data.GetString("prev") 66 | c.Next = data.GetString("next") 67 | 68 | if dataItems, ok := data["items"]; ok { 69 | if items, ok := UnmarshalItems(dataItems); ok { 70 | c.OrderedItems = items 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /streams/range.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "iter" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/benpate/hannibal/vocab" 8 | ) 9 | 10 | func (document Document) Range() iter.Seq[Document] { 11 | 12 | return func(yield func(Document) bool) { 13 | for ; document.NotNil(); document = document.Tail() { 14 | if !yield(document.Head()) { 15 | return 16 | } 17 | } 18 | } 19 | } 20 | 21 | func (document Document) RangeWithIndex() iter.Seq2[int, Document] { 22 | 23 | return func(yield func(int, Document) bool) { 24 | 25 | for index := 0; document.NotNil(); document = document.Tail() { 26 | if !yield(index, document.Head()) { 27 | return 28 | } 29 | index++ 30 | } 31 | } 32 | } 33 | 34 | // RangeIDs returns an iterator that yields the IDs of each element of a Document. 35 | // If the Document is empty, it yields no values. 36 | // If the Document is a single object, then it yields the ID of that object. 37 | // If the Document is a list, then it yields the IDs of each object in the list 38 | func (document Document) RangeIDs() iter.Seq[string] { 39 | 40 | return func(yield func(string) bool) { 41 | for ; document.NotNil(); document = document.Tail() { 42 | if !yield(document.Head().ID()) { 43 | return 44 | } 45 | } 46 | } 47 | } 48 | 49 | func (document Document) RangeMentions() iter.Seq[string] { 50 | return func(yield func(string) bool) { 51 | for tag := document.Tag(); tag.NotNil(); tag = tag.Tail() { 52 | 53 | if tag.Type() == vocab.LinkTypeMention { 54 | if !yield(tag.Href()) { 55 | return 56 | } 57 | } 58 | } 59 | } 60 | } 61 | 62 | func (document Document) RangeAddressees() iter.Seq[string] { 63 | 64 | return joinIterators( 65 | document.Actor().RangeIDs(), 66 | document.To().RangeIDs(), 67 | document.CC().RangeIDs(), 68 | document.BTo().RangeIDs(), 69 | document.BCC().RangeIDs(), 70 | document.RangeMentions(), 71 | 72 | // TODO: FEP-1b12: Group Federation: https://w3id.org/fep/1b12 73 | // TODO: FEP-7888: Demystifying the context property: https://w3id.org/fep/7888 74 | // TODO: FEP-7458: Using the replies collection: https://w3id.org/fep/7458 75 | // TODO: FEP-171b: Conversation Containers: http://w3id.org/fep/171b 76 | // TODO: FEP-f228: Backfilling conversations: https://w3id.org/fep/f228 77 | ) 78 | } 79 | 80 | func (document Document) RangeInReplyTo() iter.Seq[string] { 81 | 82 | return func(yield func(string) bool) { 83 | 84 | inReplyTo := document.InReplyTo() 85 | 86 | if inReplyTo.IsNil() { 87 | return // Nothing to yield 88 | } 89 | 90 | inReplyToDocument, err := inReplyTo.Load() 91 | 92 | if err != nil { 93 | derp.Report(derp.Wrap(err, "streams.Document.RangeInReplyTo", "Error loading InReplyTo document", inReplyTo.ID())) 94 | return // Nothing to yield 95 | } 96 | 97 | for address := range inReplyToDocument.RangeAddressees() { 98 | if !yield(address) { 99 | return // Stop yielding 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /streams/types.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import "github.com/benpate/hannibal/vocab" 4 | 5 | // DocumentCategory returns the higher level category for the provided document type: [Activity, Actor, Collection, Object] 6 | func DocumentCategory(documentType string) string { 7 | 8 | if IsActivity(documentType) { 9 | return vocab.DocumentCategoryActivity 10 | } 11 | 12 | if IsActor(documentType) { 13 | return vocab.DocumentCategoryActor 14 | } 15 | 16 | if IsCollection(documentType) { 17 | return vocab.DocumentCategoryCollection 18 | } 19 | 20 | return vocab.DocumentCategoryObject 21 | } 22 | 23 | func IsActivity(documentType string) bool { 24 | 25 | switch documentType { 26 | 27 | case vocab.ActivityTypeAccept, 28 | vocab.ActivityTypeAdd, 29 | vocab.ActivityTypeAnnounce, 30 | vocab.ActivityTypeArrive, 31 | vocab.ActivityTypeBlock, 32 | vocab.ActivityTypeCreate, 33 | vocab.ActivityTypeDelete, 34 | vocab.ActivityTypeDislike, 35 | vocab.ActivityTypeFlag, 36 | vocab.ActivityTypeFollow, 37 | vocab.ActivityTypeIgnore, 38 | vocab.ActivityTypeInvite, 39 | vocab.ActivityTypeJoin, 40 | vocab.ActivityTypeLeave, 41 | vocab.ActivityTypeLike, 42 | vocab.ActivityTypeListen, 43 | vocab.ActivityTypeMove, 44 | vocab.ActivityTypeOffer, 45 | vocab.ActivityTypeQuestion, 46 | vocab.ActivityTypeReject, 47 | vocab.ActivityTypeRead, 48 | vocab.ActivityTypeRemove, 49 | vocab.ActivityTypeTentativeReject, 50 | vocab.ActivityTypeTentativeAccept, 51 | vocab.ActivityTypeTravel, 52 | vocab.ActivityTypeUndo, 53 | vocab.ActivityTypeUpdate, 54 | vocab.ActivityTypeView: 55 | return true 56 | } 57 | 58 | return false 59 | } 60 | 61 | func IsActor(documentType string) bool { 62 | 63 | switch documentType { 64 | 65 | case vocab.ActorTypeApplication, 66 | vocab.ActorTypeGroup, 67 | vocab.ActorTypePerson, 68 | vocab.ActorTypeOrganization, 69 | vocab.ActorTypeService: 70 | return true 71 | } 72 | 73 | return false 74 | } 75 | 76 | func IsCollection(documentType string) bool { 77 | 78 | switch documentType { 79 | 80 | case vocab.CoreTypeCollection, 81 | vocab.CoreTypeCollectionPage, 82 | vocab.CoreTypeOrderedCollection, 83 | vocab.CoreTypeOrderedCollectionPage: 84 | 85 | return true 86 | } 87 | 88 | return false 89 | } 90 | 91 | func IsObject(documentType string) bool { 92 | 93 | switch documentType { 94 | 95 | case vocab.ObjectTypeArticle, 96 | vocab.ObjectTypeAudio, 97 | vocab.ObjectTypeDocument, 98 | vocab.ObjectTypeEvent, 99 | vocab.ObjectTypeImage, 100 | vocab.ObjectTypeNote, 101 | vocab.ObjectTypePage, 102 | vocab.ObjectTypePlace, 103 | vocab.ObjectTypeProfile, 104 | vocab.ObjectTypeRelationship, 105 | vocab.ObjectTypeTombstone, 106 | vocab.ObjectTypeVideo: 107 | 108 | return true 109 | } 110 | 111 | return false 112 | } 113 | -------------------------------------------------------------------------------- /streams/uniquer.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import "iter" 4 | 5 | // Uniquer is a utility class that helps to identify unique values 6 | type Uniquer[T comparable] struct { 7 | seen map[T]struct{} 8 | } 9 | 10 | // NewUniquer returns a fully initialized Uniquer object 11 | func NewUniquer[T comparable]() *Uniquer[T] { 12 | return &Uniquer[T]{ 13 | seen: make(map[T]struct{}), 14 | } 15 | } 16 | 17 | // IsUnique returns TRUE if the value has not been seen before. 18 | // Subsequent calls to IsUnique() with the same value will return FALSE. 19 | func (u *Uniquer[T]) IsUnique(id T) bool { 20 | 21 | _, ok := u.seen[id] 22 | 23 | if ok { 24 | return false 25 | } 26 | 27 | u.seen[id] = struct{}{} 28 | return true 29 | } 30 | 31 | // IsDuplicate returns TRUE if the value has been seen before. 32 | func (u *Uniquer[T]) IsDuplicate(id T) bool { 33 | return !u.IsUnique(id) 34 | } 35 | 36 | func (u *Uniquer[T]) Range(iterator iter.Seq[T]) iter.Seq[T] { 37 | return func(yield func(T) bool) { 38 | 39 | for item := range iterator { 40 | if u.IsUnique(item) { 41 | if !yield(item) { 42 | return // Stop iterating if the yield function returns false 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /streams/utils.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "iter" 5 | "strings" 6 | ) 7 | 8 | // joinIterators combines multiple rangeFunc iterators into a single iterator. 9 | func joinIterators[T any](iterators ...iter.Seq[T]) iter.Seq[T] { 10 | return func(yield func(T) bool) { 11 | for _, iterator := range iterators { 12 | for value := range iterator { 13 | if !yield(value) { 14 | return 15 | } 16 | } 17 | } 18 | } 19 | } 20 | 21 | func indexOfNoCase(str string, substring string, startPosition int) int { 22 | 23 | if startPosition < 0 { 24 | startPosition = 0 25 | } 26 | 27 | if startPosition >= len(str) { 28 | return -1 29 | } 30 | 31 | if len(substring) == 0 { 32 | return -1 33 | } 34 | 35 | if startPosition > 0 { 36 | str = str[startPosition:] 37 | } 38 | 39 | str = strings.ToLower(str) 40 | substring = strings.ToLower(substring) 41 | 42 | return strings.Index(str, substring) 43 | } 44 | -------------------------------------------------------------------------------- /test-signatures/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/benpate/derp" 10 | "github.com/benpate/hannibal/clients" 11 | "github.com/benpate/hannibal/sigs" 12 | "github.com/benpate/hannibal/streams" 13 | "github.com/davecgh/go-spew/spew" 14 | "github.com/rs/zerolog" 15 | "github.com/rs/zerolog/log" 16 | ) 17 | 18 | func main() { 19 | 20 | // Logging Configuration 21 | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix 22 | log.Logger = log.Output(zerolog.ConsoleWriter{ 23 | Out: os.Stderr, 24 | NoColor: false, 25 | TimeFormat: "", 26 | }) 27 | 28 | fmt.Println("HTTP Signature Tester") 29 | fmt.Println("This is a bare-bones tool for testing") 30 | fmt.Println("HTTP signatures. Paste in an HTTP request") 31 | fmt.Println("and it will attempt to verify the signature") 32 | fmt.Println("using the publick key found in the document.") 33 | fmt.Println("") 34 | fmt.Println("Paste HTTP Request Now:") 35 | fmt.Println("-----------------------") 36 | 37 | request, err := http.ReadRequest(bufio.NewReader(os.Stdin)) 38 | 39 | if err != nil { 40 | derp.Report(err) 41 | return 42 | } 43 | 44 | fmt.Println("") 45 | fmt.Println("") 46 | fmt.Println("Processing HTTP Request") 47 | fmt.Println("-----------------------") 48 | 49 | verifier := sigs.NewVerifier() 50 | if _, err := verifier.Verify(request, keyFinder()); err != nil { 51 | spew.Dump(err) 52 | } 53 | fmt.Println("") 54 | fmt.Println("HTTP SIGNATURE VERIFIED SUCCESSFULLY.") 55 | } 56 | 57 | func keyFinder() sigs.PublicKeyFinder { 58 | 59 | return func(keyID string) (string, error) { 60 | 61 | hashClient := clients.NewHashLookup(streams.NewDefaultClient()) 62 | 63 | document, err := hashClient.Load(keyID) 64 | 65 | if err != nil { 66 | return "", derp.Wrap(err, "hannibal.validator.HTTPSig.keyFinder", "Error retrieving Actor from ActivityPub document", keyID) 67 | } 68 | 69 | publicKeyPEM := document.PublicKeyPEM() 70 | 71 | return publicKeyPEM, nil 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package hannibal 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "time" 7 | 8 | "github.com/benpate/hannibal/vocab" 9 | ) 10 | 11 | // TimeFormat returns a string representation of the provided time value, 12 | // using the format designated by the W3C spec: https://www.w3.org/TR/activitystreams-core/#dates 13 | func TimeFormat(value time.Time) string { 14 | return value.UTC().Format(http.TimeFormat) 15 | } 16 | 17 | // IsActivityPubContentType returns TRUE if the provided contentType is a valid ActivityPub content type. 18 | // https://www.w3.org/TR/activitystreams-core/#media-type 19 | func IsActivityPubContentType(contentType string) bool { 20 | 21 | // If multiple content types are provided, then only check the first one. 22 | contentType, _, _ = strings.Cut(contentType, ",") 23 | 24 | // Strip off any parameters from the content type (like charsets and json-ld profiles) 25 | contentType, _, _ = strings.Cut(contentType, ";") 26 | 27 | // Remove whitespace around the actual value 28 | contentType = strings.TrimSpace(contentType) 29 | 30 | // If what remains matches any of these values, then Success! 31 | switch contentType { 32 | case vocab.ContentTypeActivityPub, 33 | vocab.ContentTypeJSON, 34 | vocab.ContentTypeJSONLD: 35 | return true 36 | } 37 | 38 | // Failure. 39 | return false 40 | } 41 | 42 | // IsUndoableActivity returns TRUE if the provided activityType 43 | // is one that can be undone (as opposed to an activity that must be "Deleted") 44 | func IsUndoableActivity(activityType string) bool { 45 | 46 | switch activityType { 47 | 48 | case vocab.ActivityTypeAnnounce, 49 | vocab.ActivityTypeDislike, 50 | vocab.ActivityTypeFollow, 51 | vocab.ActivityTypeLike, 52 | vocab.ActivityTypeBlock: 53 | return true 54 | } 55 | 56 | return false 57 | } 58 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package hannibal 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestIsActivityPubContentType(t *testing.T) { 10 | require.True(t, IsActivityPubContentType("application/json")) 11 | require.True(t, IsActivityPubContentType("application/json; everything after the semicolon is ignored")) 12 | require.True(t, IsActivityPubContentType("application/json; whocares=notme")) 13 | require.True(t, IsActivityPubContentType("application/activity+json")) 14 | require.True(t, IsActivityPubContentType("application/activity+json; charset=utf-8")) 15 | require.True(t, IsActivityPubContentType("application/ld+json")) 16 | require.True(t, IsActivityPubContentType("application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")) 17 | 18 | require.False(t, IsActivityPubContentType("literally anything else")) 19 | require.False(t, IsActivityPubContentType("application/xml")) 20 | require.False(t, IsActivityPubContentType("application/xml; whocares=notme")) 21 | require.False(t, IsActivityPubContentType("image/webp")) 22 | } 23 | -------------------------------------------------------------------------------- /validator/deleted-objects.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/benpate/hannibal/streams" 8 | "github.com/benpate/hannibal/vocab" 9 | "github.com/benpate/remote" 10 | ) 11 | 12 | // DeletedObject validates "delete" activities by trying to retrieve the original object. 13 | type DeletedObject struct{} 14 | 15 | // NewDeletedObject returns a fully initialized DeletedObject validator. 16 | func NewDeletedObject() DeletedObject { 17 | return DeletedObject{} 18 | } 19 | 20 | // Validate implements the Validator interface, which performs the actual validation. 21 | func (v DeletedObject) Validate(request *http.Request, document *streams.Document) Result { 22 | 23 | const location = "hannibal.validator.DeletedObject" 24 | 25 | // Only validate "Delete" activities 26 | if document.Type() != vocab.ActivityTypeDelete { 27 | return ResultUnknown 28 | } 29 | 30 | // Wait for ten minutes before checking. 31 | // TODO: This job should be queued (or something) in the future. 32 | // time.Sleep(10 * time.Minute) 33 | 34 | // Retrieve the objectID from the document 35 | objectID := document.Object().ID() 36 | 37 | if objectID == "" { 38 | return ResultInvalid 39 | } 40 | 41 | // log.Trace().Str("objectID", objectID).Str("location", location).Msg("Validating DeletedObject") 42 | 43 | // Try to retrieve the original document 44 | txn := remote.Get(objectID). 45 | Header("Accept", "application/activity+json") 46 | 47 | if err := txn.Send(); err != nil { 48 | 49 | // log.Trace().Err(err).Int("code", derp.ErrorCode(err)).Str("location", location).Msg("Received error code") 50 | 51 | // If the document is marked "gone" or "not found", 52 | // then this "delete" transaction is valid. 53 | switch derp.ErrorCode(err) { 54 | case http.StatusNotFound, http.StatusGone: 55 | return ResultValid 56 | } 57 | 58 | // We're not expecting this error, so perhaps there's something else going on here. 59 | derp.Report(derp.Wrap(err, location, "Error retrieving document, but it is not 'gone' or 'not found'")) 60 | return ResultUnknown 61 | } 62 | 63 | // Log the server response 64 | // log.Trace().Str("location", location).Msg("Delete is invalid / document still exists") 65 | // body, err := io.ReadAll(txn.Response().Body) 66 | // log.Trace().Err(err).Msg(string(body)) 67 | 68 | // Fall through means that the document still exists, so the "delete" transaction is invalid. 69 | return ResultInvalid 70 | } 71 | -------------------------------------------------------------------------------- /validator/http-lookup.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/benpate/hannibal/property" 8 | "github.com/benpate/hannibal/streams" 9 | "github.com/benpate/hannibal/vocab" 10 | ) 11 | 12 | // HTTPLookup is a Validator that tries to retrieve the original document from the source server 13 | type HTTPLookup struct{} 14 | 15 | func NewHTTPLookup() HTTPLookup { 16 | return HTTPLookup{} 17 | } 18 | 19 | func (v HTTPLookup) Validate(request *http.Request, document *streams.Document) Result { 20 | 21 | // return ResultUnknown 22 | 23 | const location = "hannibal.validator.HTTPLookup" 24 | 25 | switch document.Type() { 26 | case vocab.ActivityTypeCreate, vocab.ActivityTypeUpdate: 27 | default: 28 | return ResultUnknown 29 | } 30 | 31 | // Get the ObjectID of the document 32 | objectID := document.Object().ID() 33 | 34 | if objectID == "" { 35 | return ResultInvalid 36 | } 37 | 38 | // Load the original document 39 | original, err := document.Client().Load(objectID) 40 | 41 | if err != nil { 42 | derp.Report(derp.Wrap(err, location, "Error loading original document", objectID)) 43 | } 44 | 45 | // Extract the value from the "original" retrieved document and replace it int the 46 | // document that was passed in 47 | value := original.Value() 48 | propertyValue := property.NewValue(value) 49 | document.SetValue(propertyValue) 50 | 51 | // Return in triumph 52 | return ResultValid 53 | } 54 | -------------------------------------------------------------------------------- /validator/http-signatures.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/benpate/hannibal/sigs" 8 | "github.com/benpate/hannibal/streams" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | // HTTPSig is a Validator that checks incoming HTTP requests 13 | // using the HTTP signatures algorithm. 14 | // https://docs.joinmastodon.org/spec/security/ 15 | type HTTPSig struct{} 16 | 17 | func NewHTTPSig() HTTPSig { 18 | return HTTPSig{} 19 | } 20 | 21 | // Validate uses the hannibal/sigs library to verify that the HTTP 22 | // request is signed with a valid key. 23 | func (validator HTTPSig) Validate(request *http.Request, document *streams.Document) Result { 24 | 25 | if !sigs.HasSignature(request) { 26 | return ResultUnknown 27 | } 28 | 29 | // Find the public key for the Actor who signed this request 30 | keyFinder := validator.keyFinder(document) 31 | 32 | // Verify the request using the Actor's public key 33 | if _, err := sigs.Verify(request, keyFinder); err != nil { 34 | log.Trace().Err(err).Msg("Hannibal Inbox: Error verifying HTTP Signature") 35 | return ResultInvalid 36 | } 37 | 38 | log.Trace().Msg("Hannibal Inbox: HTTP Signature Verified") 39 | return ResultValid 40 | } 41 | 42 | // keyFinder looks up the public Key for the provided document/Actor using the 43 | // HTTP client in the document. 44 | func (validator HTTPSig) keyFinder(document *streams.Document) sigs.PublicKeyFinder { 45 | 46 | const location = "hannibal.validator.HTTPSig.keyFinder" 47 | 48 | return func(keyID string) (string, error) { 49 | 50 | // Load the Actor from the document 51 | actor, err := document.Actor().Load() 52 | 53 | if err != nil { 54 | return "", derp.Wrap(err, location, "Error retrieving Actor from ActivityPub document", document.Value()) 55 | } 56 | 57 | // Search the Actor's public keys for the one that matches the provided keyID 58 | for key := actor.PublicKey(); key.NotNil(); key = key.Tail() { 59 | 60 | if key.ID() == keyID { 61 | return key.PublicKeyPEM(), nil 62 | } 63 | } 64 | 65 | // If none match, then return a (hopefully informative) error. 66 | log.Trace().Str("keyId", keyID).Msg("Hannibal Inbox: Could not find remote actor's public key") 67 | return "", derp.BadRequestError(location, "Actor must publish the key used to sign this request", actor.ID(), keyID) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /validator/identityProofs.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/benpate/hannibal/streams" 7 | ) 8 | 9 | // IdentityProof implements FEP-c390 Identity Proofs 10 | // https://codeberg.org/fediverse/fep/src/branch/main/fep/c390/fep-c390.md 11 | type IdentityProof struct{} 12 | 13 | func (v IdentityProof) Validate(request *http.Request, document streams.Document) Result { 14 | 15 | return ResultUnknown 16 | } 17 | -------------------------------------------------------------------------------- /validator/result.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | // Result indicates the outcome of a validation attempt 4 | type Result string 5 | 6 | // ResultValid indicates that the current Validator has successfully validated the HTTP request 7 | const ResultValid Result = "VALID" 8 | 9 | // ResultInvalid indicates that the current Validator can say with certainty that the HTTP request is invalid 10 | const ResultInvalid Result = "INVALID" 11 | 12 | // ResultUnknown indicates that the current Validator cannot say that the HTTP request is valid or invalid 13 | const ResultUnknown Result = "UNKNOWN" 14 | 15 | // ResultError indicates that the current Validator encountered an error while attempting to validate the HTTP request 16 | const ResultError Result = "ERROR" 17 | -------------------------------------------------------------------------------- /vocab/README.md: -------------------------------------------------------------------------------- 1 | ## Hannibal / vocab 2 | 3 | This package contains all of the commonly used constants from the current 4 | [ActivityStreams Vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/), 5 | along with a couple of simple validation functions that verify that a string 6 | input matches certain designated types. 7 | 8 | This package is used by the other Hannibal packages to guarantee that we're always 9 | using the right values in the right places. -------------------------------------------------------------------------------- /vocab/activityTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-accept 4 | const ActivityTypeAccept = "Accept" 5 | 6 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-add 7 | const ActivityTypeAdd = "Add" 8 | 9 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-announce 10 | const ActivityTypeAnnounce = "Announce" 11 | 12 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-arrive 13 | const ActivityTypeArrive = "Arrive" 14 | 15 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-block 16 | const ActivityTypeBlock = "Block" 17 | 18 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-create 19 | const ActivityTypeCreate = "Create" 20 | 21 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-delete 22 | const ActivityTypeDelete = "Delete" 23 | 24 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-dislike 25 | const ActivityTypeDislike = "Dislike" 26 | 27 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-flag 28 | const ActivityTypeFlag = "Flag" 29 | 30 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-follow 31 | const ActivityTypeFollow = "Follow" 32 | 33 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-ignore 34 | const ActivityTypeIgnore = "Ignore" 35 | 36 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-invite 37 | const ActivityTypeInvite = "Invite" 38 | 39 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-join 40 | const ActivityTypeJoin = "Join" 41 | 42 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-leave 43 | const ActivityTypeLeave = "Leave" 44 | 45 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like 46 | const ActivityTypeLike = "Like" 47 | 48 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-listen 49 | const ActivityTypeListen = "Listen" 50 | 51 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-move 52 | const ActivityTypeMove = "Move" 53 | 54 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-offer 55 | const ActivityTypeOffer = "Offer" 56 | 57 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-question 58 | const ActivityTypeQuestion = "Question" 59 | 60 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-reject 61 | const ActivityTypeReject = "Reject" 62 | 63 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-read 64 | const ActivityTypeRead = "Read" 65 | 66 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-remove 67 | const ActivityTypeRemove = "Remove" 68 | 69 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tentativereject 70 | const ActivityTypeTentativeReject = "TentativeReject" 71 | 72 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tentativeaccept 73 | const ActivityTypeTentativeAccept = "TentativeAccept" 74 | 75 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-travel 76 | const ActivityTypeTravel = "Travel" 77 | 78 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-undo 79 | const ActivityTypeUndo = "Undo" 80 | 81 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-update 82 | const ActivityTypeUpdate = "Update" 83 | 84 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-view 85 | const ActivityTypeView = "View" 86 | 87 | // http://w3id.org/fep/c0e0 88 | // http://litepub.social/ns#EmojiReact 89 | const ActivityTypeEmojiReact = "EmojiReact" 90 | 91 | // Not defined in an official spec/fep, but used in the wild, go figure 92 | // http://w3id.org/fep/c0e0 93 | const ActivityTypeEmojiReactAlt = "EmojiReaction" 94 | -------------------------------------------------------------------------------- /vocab/actorPropertyTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // https://www.w3.org/TR/activitypub/#inbox 4 | const PropertyInbox = "inbox" 5 | 6 | // https://www.w3.org/TR/activitypub/#outbox 7 | const PropertyOutbox = "outbox" 8 | 9 | // https://www.w3.org/TR/activitypub/#following 10 | const PropertyFollowing = "following" 11 | 12 | // https://www.w3.org/TR/activitypub/#followers 13 | const PropertyFollowers = "followers" 14 | 15 | // https://www.w3.org/TR/activitypub/#liked 16 | const PropertyLiked = "liked" 17 | 18 | // https://www.w3.org/TR/activitypub/#likes 19 | const PropertyLikes = "likes" 20 | 21 | const PropertyBlocked = "blocked" 22 | 23 | const PropertyStreams = "streams" 24 | 25 | const PropertyPreferredUsername = "preferredUsername" 26 | 27 | const PropertyEndpoints = "endpoints" 28 | -------------------------------------------------------------------------------- /vocab/actorTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-application 4 | const ActorTypeApplication = "Application" 5 | 6 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-group 7 | const ActorTypeGroup = "Group" 8 | 9 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-organization 10 | const ActorTypeOrganization = "Organization" 11 | 12 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person 13 | const ActorTypePerson = "Person" 14 | 15 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-service 16 | const ActorTypeService = "Service" 17 | -------------------------------------------------------------------------------- /vocab/contentTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // Accept is the string used in the HTTP header to request a response be encoded as a MIME type 4 | const Accept = "Accept" 5 | 6 | // ContentType is the string used in the HTTP header to designate a MIME type 7 | const ContentType = "Content-Type" 8 | 9 | // ContentTypeActivityPub is the standard MIME type for ActivityPub content 10 | const ContentTypeActivityPub = "application/activity+json" 11 | 12 | // ContentTypeJSONLD is the standard MIME Type for JSON-LD content 13 | // https://en.wikipedia.org/wiki/JSON-LD 14 | const ContentTypeJSONLD = "application/ld+json" 15 | 16 | // ContentTypeJSONLDWithProfile is the standard MIME Type for JSON-LD content, with profile 17 | // to designate ActivityPub content. 18 | // https://www.w3.org/TR/activitystreams-core/#media-type 19 | const ContentTypeJSONLDWithProfile = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` 20 | 21 | // ContentTypeHTML is the standard MIME type for HTML content 22 | const ContentTypeHTML = "text/html" 23 | 24 | // ContentTypeJSON is the standard MIME Type for JSON content 25 | const ContentTypeJSON = "application/json" 26 | 27 | // ContentTypeJSONResourceDescriptor is the standard MIME Type for JSON Resource Descriptor content 28 | // which is used by WebFinger: https://datatracker.ietf.org/doc/html/rfc7033#section-10.2 29 | const ContentTypeJSONResourceDescriptor = "application/jrd+json" 30 | -------------------------------------------------------------------------------- /vocab/contextTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // ContextTypeActivityStreams defines the standard ActivityStreams vocabulary. 4 | // https://www.w3.org/TR/activitystreams-core/ 5 | const ContextTypeActivityStreams = "https://www.w3.org/ns/activitystreams" 6 | 7 | // ContextTypeSecurity describes the standard security vocabulary for the Fediverse. 8 | // https://w3c.github.io/vc-data-integrity/vocab/security/vocabulary.html 9 | const ContextTypeSecurity = "https://w3id.org/security/v1" 10 | 11 | // https://joinmastodon.org/ns# 12 | var ContextTypeToot = map[string]any{ 13 | "toot": "https://joinmastodon.org/ns#", 14 | "discoverable": "toot:discoverable", 15 | "indexable": "toot:indexable", 16 | "featured": map[string]any{ 17 | "@id": "http://joinmastodon.org/ns#featured", 18 | "@type": "@id", 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /vocab/coreTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-object 4 | const CoreTypeObject = "Object" 5 | 6 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-link 7 | const CoreTypeLink = "Link" 8 | 9 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-activity 10 | const CoreTypeActivity = "Activity" 11 | 12 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-intransitiveactivity 13 | const CoreTypeIntransitiveActivity = "IntransitiveActivity" 14 | 15 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collection 16 | const CoreTypeCollection = "Collection" 17 | 18 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection 19 | const CoreTypeOrderedCollection = "OrderedCollection" 20 | 21 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage 22 | const CoreTypeCollectionPage = "CollectionPage" 23 | 24 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollectionpage 25 | const CoreTypeOrderedCollectionPage = "OrderedCollectionPage" 26 | -------------------------------------------------------------------------------- /vocab/customTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // Any is used for matching any value 4 | const Any = "*" 5 | 6 | // Unknown is a constant for when a value is. 7 | const Unknown = "Unknown" 8 | -------------------------------------------------------------------------------- /vocab/documentTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // Identifies documents that are "Activity" types [Announce, Like, Dislike, ...] 4 | // This value is not defined explicityly by the W3C spec 5 | const DocumentCategoryActivity = "Activity" 6 | 7 | // Identifies documents that are "Actor" types: [Application, Group, Organization, Person, Service] 8 | // This value is not defined explicitly by the W3C spec 9 | const DocumentCategoryActor = "Actor" 10 | 11 | // Identifies documents that are "Object" types: [Image, Video, Audio, Document] 12 | // This value is not defined explicitly by the W3C spec 13 | const DocumentCategoryObject = "Object" 14 | 15 | // Identifies documents that are "Collection" types: [Collection, OrderedCollection] 16 | // This value is not defined explicitly by the W3C spec 17 | const DocumentCategoryCollection = "Collection" 18 | -------------------------------------------------------------------------------- /vocab/linkTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-mention 4 | const LinkTypeMention = "Mention" 5 | 6 | // Discussed, but not defined in https://www.w3.org/TR/activitystreams-vocabulary/#h-microsyntaxes 7 | // https://socialhub.activitypub.rocks/t/formally-defining-a-hashtag-type/3429 8 | // Because "ActivityPub"... 9 | const LinkTypeHashtag = "Hashtag" 10 | -------------------------------------------------------------------------------- /vocab/namespaces.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | const NamespaceActivityStreams = "https://www.w3.org/ns/activitystreams" 4 | 5 | const NamespaceActivityStreamsPublic = "https://www.w3.org/ns/activitystreams#Public" 6 | -------------------------------------------------------------------------------- /vocab/objectTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-article 4 | const ObjectTypeArticle = "Article" 5 | 6 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audio 7 | const ObjectTypeAudio = "Audio" 8 | 9 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document 10 | const ObjectTypeDocument = "Document" 11 | 12 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event 13 | const ObjectTypeEvent = "Event" 14 | 15 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image 16 | const ObjectTypeImage = "Image" 17 | 18 | // https://w3c-ccg.github.io/security-vocab/#publicKey 19 | const ObjectTypeKey = "Key" 20 | 21 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note 22 | const ObjectTypeNote = "Note" 23 | 24 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-page 25 | const ObjectTypePage = "Page" 26 | 27 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-place 28 | const ObjectTypePlace = "Place" 29 | 30 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-profile 31 | const ObjectTypeProfile = "Profile" 32 | 33 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-relationship 34 | const ObjectTypeRelationship = "Relationship" 35 | 36 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone 37 | const ObjectTypeTombstone = "Tombstone" 38 | 39 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-video 40 | const ObjectTypeVideo = "Video" 41 | -------------------------------------------------------------------------------- /vocab/relationType.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | const RelationTypeAnnounce = "Announce" 4 | 5 | const RelationTypeLike = "Like" 6 | 7 | const RelationTypeDislike = "Dislike" 8 | 9 | const RelationTypeReply = "Reply" 10 | -------------------------------------------------------------------------------- /vocab/securityTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // https://w3c.github.io/vc-data-integrity/vocab/security/vocabulary.html#publicKey 4 | const PropertyPublicKey = "publicKey" 5 | 6 | // https://w3c.github.io/vc-data-integrity/vocab/security/vocabulary.html#publicKeyPem 7 | const PropertyPublicKeyPEM = "publicKeyPem" 8 | -------------------------------------------------------------------------------- /vocab/validators.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // ValidateActivityType validates the ActivityPub "type" of the given activity. 4 | // It returns "UNKNOWN" if the type is not recognized. 5 | func ValidateActivityType(activityType string) string { 6 | 7 | switch activityType { 8 | 9 | case ActivityTypeAccept, 10 | ActivityTypeAdd, 11 | ActivityTypeAnnounce, 12 | ActivityTypeArrive, 13 | ActivityTypeBlock, 14 | ActivityTypeCreate, 15 | ActivityTypeDelete, 16 | ActivityTypeDislike, 17 | ActivityTypeFlag, 18 | ActivityTypeFollow, 19 | ActivityTypeIgnore, 20 | ActivityTypeInvite, 21 | ActivityTypeJoin, 22 | ActivityTypeLeave, 23 | ActivityTypeLike, 24 | ActivityTypeListen, 25 | ActivityTypeMove, 26 | ActivityTypeOffer, 27 | ActivityTypeQuestion, 28 | ActivityTypeReject, 29 | ActivityTypeRead, 30 | ActivityTypeRemove, 31 | ActivityTypeTentativeReject, 32 | ActivityTypeTentativeAccept, 33 | ActivityTypeTravel, 34 | ActivityTypeUndo, 35 | ActivityTypeUpdate, 36 | ActivityTypeView: 37 | 38 | return activityType 39 | } 40 | 41 | return Unknown 42 | } 43 | 44 | // ValidateActorType validates the ActivityPub "type" of the given actor. 45 | // It returns "UNKNOWN" if the type is not recognized. 46 | func ValidateActorType(actorType string) string { 47 | 48 | switch actorType { 49 | 50 | case ActorTypeApplication, 51 | ActorTypeGroup, 52 | ActorTypeOrganization, 53 | ActorTypePerson, 54 | ActorTypeService: 55 | 56 | return actorType 57 | } 58 | 59 | return Unknown 60 | } 61 | 62 | // ValidateObjectType validates the ActivityPub "type" of the given object or link. 63 | // It returns "UNKNOWN" if the type is not recognized. 64 | func ValidateObjectType(objectType string) string { 65 | 66 | switch objectType { 67 | 68 | case ObjectTypeArticle, 69 | ObjectTypeAudio, 70 | ObjectTypeDocument, 71 | ObjectTypeEvent, 72 | ObjectTypeImage, 73 | ObjectTypeNote, 74 | ObjectTypePage, 75 | ObjectTypePlace, 76 | ObjectTypeProfile, 77 | ObjectTypeRelationship, 78 | ObjectTypeTombstone, 79 | ObjectTypeVideo, 80 | LinkTypeMention: 81 | 82 | return objectType 83 | } 84 | 85 | return Unknown 86 | } 87 | --------------------------------------------------------------------------------