├── .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 | Oil painting titled: Hannibal in the Alps, by R.B. Davis 4 | 5 | [![GoDoc](https://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](http://pkg.go.dev/github.com/benpate/hannibal) 6 | [![Version](https://img.shields.io/github/v/release/benpate/hannibal?include_prereleases&style=flat-square&color=brightgreen)](https://github.com/benpate/hannibal/releases) 7 | [![Build Status](https://img.shields.io/github/actions/workflow/status/benpate/hannibal/go.yml?style=flat-square)](https://github.com/benpate/hannibal/actions/workflows/go.yml) 8 | [![Go Report Card](https://goreportcard.com/badge/github.com/benpate/hannibal?style=flat-square)](https://goreportcard.com/report/github.com/benpate/hannibal) 9 | [![Codecov](https://img.shields.io/codecov/c/github/benpate/hannibal.svg?style=flat-square)](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 | --------------------------------------------------------------------------------