├── .build.yml ├── .containerignore ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── actor.go ├── assets.go ├── bin └── .gitkeep ├── cmd ├── ctl │ └── main.go └── oni │ └── main.go ├── dev.go ├── go.mod ├── handlers.go ├── helpers.go ├── images ├── .gitignore ├── Makefile ├── build.sh ├── gen-certs.sh └── image.sh ├── internal └── esbuild │ └── main.go ├── log.go ├── oauth.go ├── oni.go ├── package.json ├── prod.go ├── render.go ├── src ├── css │ ├── main.css │ └── reset.css ├── icons.svg ├── js │ ├── activity-pub-activity.jsx │ ├── activity-pub-actor.jsx │ ├── activity-pub-audio.jsx │ ├── activity-pub-collection.jsx │ ├── activity-pub-event.jsx │ ├── activity-pub-image.jsx │ ├── activity-pub-item.jsx │ ├── activity-pub-note.jsx │ ├── activity-pub-object.jsx │ ├── activity-pub-tag.jsx │ ├── activity-pub-tombstone.jsx │ ├── activity-pub-video.jsx │ ├── auth-controller.js │ ├── bandcamp-embed.jsx │ ├── client.js │ ├── login-elements.jsx │ ├── main.jsx │ ├── natural-language-values.jsx │ ├── oni-collection-links.jsx │ ├── oni-errors.jsx │ ├── oni-header.jsx │ ├── oni-icon.jsx │ ├── oni-main.jsx │ └── utils.js └── robots.txt ├── templates ├── components │ ├── errors.html │ ├── item.html │ └── person.html ├── login.html └── main.html └── webfinger.go /.build.yml: -------------------------------------------------------------------------------- 1 | image: archlinux 2 | secrets: 3 | - 3f30fd61-e33d-4198-aafb-0ff341e9db1c 4 | - 3dcea276-38d6-4a7e-85e5-20cbc903e1ea 5 | packages: 6 | - go 7 | - yarn 8 | - podman 9 | - buildah 10 | sources: 11 | - https://git.sr.ht/~mariusor/oni 12 | environment: 13 | BUILDAH_ISOLATION: chroot 14 | tasks: 15 | - setup: | 16 | cd oni && make download && go mod vendor 17 | - build: | 18 | cd oni 19 | make clean oni 20 | - tests: | 21 | cd oni 22 | make test 23 | - coverage: | 24 | set -a +x 25 | cd oni 26 | make coverage 27 | - images: | 28 | set -a +x 29 | source ~/.buildah.env 30 | 31 | _user=$(id -un) 32 | 33 | echo 'unqualified-search-registries = ["docker.io"]' | sudo tee /etc/containers/registries.conf.d/unq-search.conf 34 | echo "${_user}:10000:65536" | sudo tee /etc/subuid 35 | echo "${_user}:10000:65536" | sudo tee /etc/subgid 36 | podman system migrate 37 | 38 | podman login -u="${BUILDAH_USER}" -p="${BUILDAH_SECRET}" quay.io 39 | 40 | set -- 41 | cd oni || exit 42 | 43 | _sha=$(git rev-parse --short HEAD) 44 | _branch=$(git branch --points-at=${_sha} | tail -n1 | tr -d '* ') 45 | _version=$(printf "%s-%s" "${_branch}" "${_sha}") 46 | 47 | make VERSION=${_version} -C images cert builder 48 | 49 | _push() { 50 | make -C images ENV=dev VERSION="${_version}" push 51 | if [ "${_branch}" = "master" ]; then 52 | make -C images ENV=qa VERSION="${_version}" push 53 | fi 54 | 55 | _tag=$(git describe --long --tags || true) 56 | if [ -n "${_tag}" ]; then 57 | make -C images ENV=prod VERSION="${_tag}" push 58 | fi 59 | } 60 | _push 61 | - push_to_github: | 62 | test "${BUILD_SUBMITTER}" != "git.sr.ht" && complete-build 63 | set -a +x 64 | ssh-keyscan -H github.com >> ~/.ssh/known_hosts 65 | 66 | cd oni 67 | git remote add hub git@github.com:mariusor/oni 68 | git push hub --force --all 69 | -------------------------------------------------------------------------------- /.containerignore: -------------------------------------------------------------------------------- 1 | **/.git/ 2 | **/.idea/ 3 | **/bin/ 4 | **/tests/ 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | static/ 4 | bin/* 5 | !bin/.gitkeep 6 | 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Marius Orcsik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := sh 2 | .ONESHELL: 3 | .SHELLFLAGS := -eu -o pipefail -c 4 | .DELETE_ON_ERROR: 5 | MAKEFLAGS += --warn-undefined-variables 6 | MAKEFLAGS += --no-builtin-rules 7 | 8 | PROJECT_NAME ?= oni 9 | ENV ?= dev 10 | 11 | LDFLAGS ?= -X main.version=$(VERSION) 12 | BUILDFLAGS ?= -a -ldflags '$(LDFLAGS)' -tags "$(TAGS)" 13 | TEST_FLAGS ?= -count=1 14 | 15 | UPX = upx 16 | YARN ?= yarn 17 | GO ?= go 18 | 19 | GO_SOURCES := $(wildcard ./*.go) 20 | TS_SOURCES := $(wildcard src/js/*) 21 | CSS_SOURCES := $(wildcard src/css/*) 22 | SVG_SOURCES := $(wildcard src/*.svg) 23 | ROBOTS_TXT := $(wildcard src/robots.txt) 24 | 25 | TAGS := $(ENV) 26 | 27 | export CGO_ENABLED=0 28 | 29 | ifneq ($(ENV), dev) 30 | LDFLAGS += -s -w -extldflags "-static" 31 | BUILDFLAGS += -trimpath 32 | endif 33 | 34 | ifeq ($(shell git describe --always > /dev/null 2>&1 ; echo $$?), 0) 35 | BRANCH=$(shell git rev-parse --abbrev-ref HEAD | tr '/' '-') 36 | HASH=$(shell git rev-parse --short HEAD) 37 | VERSION ?= $(shell printf "%s-%s" "$(BRANCH)" "$(HASH)") 38 | endif 39 | ifeq ($(shell git describe --tags > /dev/null 2>&1 ; echo $$?), 0) 40 | VERSION ?= $(shell git describe --tags | tr '/' '-') 41 | endif 42 | 43 | BUILD := $(GO) build $(BUILDFLAGS) 44 | TEST := $(GO) test $(BUILDFLAGS) 45 | 46 | .PHONY: all assets test coverage download clean 47 | 48 | all: $(PROJECT_NAME) ctl 49 | 50 | download: go.sum 51 | 52 | go.sum: go.mod 53 | $(GO) mod download all 54 | $(GO) mod tidy 55 | $(GO) get oni 56 | 57 | $(PROJECT_NAME): go.mod bin/$(PROJECT_NAME) 58 | bin/$(PROJECT_NAME): cmd/oni/main.go $(GO_SOURCES) go.mod go.sum static/main.css static/main.js static/icons.svg 59 | $(BUILD) -o $@ cmd/oni/main.go 60 | ifneq ($(ENV),dev) 61 | $(UPX) -q --mono --no-progress --best $@ || true 62 | endif 63 | 64 | ctl: bin/ctl 65 | bin/ctl: go.mod go.sum cmd/ctl/main.go $(GO_SOURCES) 66 | $(BUILD) -o $@ cmd/ctl/main.go 67 | ifneq ($(ENV),dev) 68 | $(UPX) -q --mono --no-progress --best $@ || true 69 | endif 70 | 71 | yarn.lock: 72 | $(YARN) install 73 | 74 | assets: static/main.css static/main.js static/icons.svg static/robots.txt 75 | 76 | static/main.js: $(TS_SOURCES) yarn.lock 77 | go generate -v assets.go 78 | 79 | static/main.css: $(CSS_SOURCES) yarn.lock 80 | go generate -v assets.go 81 | 82 | static/icons.svg: $(SVG_SOURCES) 83 | go generate -v assets.go 84 | 85 | static/robots.txt: $(ROBOTS_TXT) 86 | go generate -v assets.go 87 | 88 | clean: ## Cleanup the build workspace. 89 | -$(RM) bin/* 90 | -$(RM) -r ./node_modules yarn.lock 91 | -$(RM) static/*.{js,css,map,svg} 92 | -$(RM) $(PROJECT_NAME).coverprofile 93 | $(GO) clean 94 | $(MAKE) -C images $@ 95 | 96 | images: ## Build podman images. 97 | $(MAKE) -C images $@ 98 | 99 | test: TEST_TARGET := ./... 100 | test: download go.sum ## Run unit tests for the service. 101 | $(TEST) $(TEST_FLAGS) $(TEST_TARGET) 102 | 103 | coverage: TEST_TARGET := . 104 | coverage: TEST_FLAGS += -covermode=count -coverprofile $(PROJECT_NAME).coverprofile 105 | coverage: test 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Oni 2 | 3 | Is a single instance ActivityPub server compatible with Mastodon and the rest of the Fediverse. 4 | 5 | 6 | ## Getting the source 7 | 8 | ```sh 9 | $ git clone https://git.sr.ht/~mariusor/oni 10 | $ cd oni 11 | ``` 12 | 13 | ## Features 14 | 15 | Posting to an ONI instance is done using Client to Server ActivityPub. 16 | 17 | The application supports text posts, image, audio and video uploads. 18 | 19 | ## Compiling 20 | 21 | ```sh 22 | # We need to download the JavaScript dependencies, using yarn or npm 23 | # yarn install 24 | # npm install 25 | $ go mod tidy 26 | $ go generate frontend.go 27 | $ go build -trimpath -a -ldflags '-s -w -extldflags "-static"' -o $(go env GOPATH)/bin/oni ./cmd/oni/main.go 28 | ``` 29 | 30 | ## Run server 31 | 32 | ```sh 33 | # -listen can be a tcp socket, a domain socket, or the magic string "systemd" 34 | # The later should be used if running as a systemd service with socket activation 35 | $ oni -listen 127.0.4.2:4567 -path ~/.cache/oni 36 | ``` 37 | 38 | ## Add root actor 39 | 40 | ```sh 41 | # Creates an actor for URL https://johndoe.example.com and adds an OAuth2 client application with name 'johndoe.example.com' 42 | # with OAuth2 client password 'SuperSecretOAuth2ClientPassword'. 43 | # The --with-token boolean flag can make the application generate an Authorization header containing a Bearer token 44 | # usable directly in an ActivityPub client. 45 | $ onictl actor add --pw SuperSecretOAuth2ClientPassword https://johndoe.example.com 46 | ``` 47 | 48 | ## Block remote instances 49 | 50 | ```sh 51 | # Blocks all access to all johndoe.example.com pages for any access that has requests with Authorization 52 | # headers generated for actors hosted on naughty.social 53 | $ onictl block --client https://johndoe.example.com https://naughty.social 54 | ``` -------------------------------------------------------------------------------- /actor.go: -------------------------------------------------------------------------------- 1 | package oni 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rsa" 6 | "fmt" 7 | "html/template" 8 | "net/url" 9 | "path/filepath" 10 | "time" 11 | 12 | "git.sr.ht/~mariusor/lw" 13 | vocab "github.com/go-ap/activitypub" 14 | "github.com/go-ap/errors" 15 | "github.com/go-ap/filters" 16 | "github.com/go-ap/processing" 17 | "github.com/openshift/osin" 18 | ) 19 | 20 | var ( 21 | iconOni = `` 22 | nameOni = "Oni" 23 | descriptionOni = `Single user ActivityPub service.` 24 | contentOniTemplate = template.Must( 25 | template.New("content"). 26 | Parse(`

Congratulations!

27 |

You have successfully started your default Oni server.
28 | You're currently running version {{ .Version }}.
29 | The server can be accessed at {{ .URL }}.
30 |

`)) 31 | ) 32 | 33 | func DefaultActor(iri vocab.IRI) vocab.Actor { 34 | contentOni := bytes.Buffer{} 35 | _ = contentOniTemplate.Execute(&contentOni, struct { 36 | Version string 37 | URL string 38 | }{Version: Version, URL: iri.String()}) 39 | 40 | actor := vocab.Actor{ 41 | ID: iri, 42 | Type: vocab.ApplicationType, 43 | PreferredUsername: DefaultValue(nameOni), 44 | Summary: DefaultValue(descriptionOni), 45 | Content: DefaultValue(contentOni.String()), 46 | Inbox: vocab.Inbox.Of(iri), 47 | Outbox: vocab.Outbox.Of(iri), 48 | Audience: vocab.ItemCollection{vocab.PublicNS}, 49 | Icon: vocab.Object{ 50 | Type: vocab.ImageType, 51 | MediaType: "image/svg+xml", 52 | Content: DefaultValue(iconOni), 53 | }, 54 | // NOTE(marius): we create a blank PublicKey so the server doesn't have outbound federation enabled. 55 | PublicKey: PublicKey(iri, nil), 56 | } 57 | 58 | return actor 59 | } 60 | 61 | func PublicKey(iri vocab.IRI, prvKey *rsa.PrivateKey) vocab.PublicKey { 62 | return vocab.PublicKey{ 63 | ID: vocab.IRI(fmt.Sprintf("%s#main", iri)), 64 | Owner: iri, 65 | PublicKeyPem: pemEncodePublicKey(prvKey), 66 | } 67 | } 68 | 69 | type Control struct { 70 | Storage FullStorage 71 | Logger lw.Logger 72 | } 73 | 74 | func (c *Control) CreateActor(iri vocab.IRI, maybePw string, withToken bool) (*vocab.Actor, error) { 75 | pw := DefaultOAuth2ClientPw 76 | if maybePw != "" { 77 | pw = maybePw 78 | } 79 | 80 | it, err := c.Storage.Load(iri) 81 | if err == nil || (!vocab.IsNil(it) && it.GetLink().Equals(iri, true)) { 82 | if err != nil && !errors.IsNotFound(err) { 83 | c.Logger.WithContext(lw.Ctx{"iri": iri, "err": err.Error()}).Warnf("Actor already exists") 84 | } else { 85 | c.Logger.WithContext(lw.Ctx{"iri": iri}).Warnf("Actor already exists") 86 | } 87 | return nil, err 88 | } 89 | 90 | o := DefaultActor(iri) 91 | o.Followers = vocab.Followers.Of(iri) 92 | o.Following = vocab.Following.Of(iri) 93 | 94 | if it, err = c.Storage.Save(o); err != nil { 95 | c.Logger.WithContext(lw.Ctx{"iri": iri, "err": err.Error()}).Errorf("Unable to save main actor") 96 | return nil, err 97 | } else { 98 | c.Logger.WithContext(lw.Ctx{"iri": it.GetID()}).Infof("Created root actor") 99 | } 100 | 101 | actor, err := vocab.ToActor(it) 102 | if err != nil { 103 | c.Logger.WithContext(lw.Ctx{"iri": iri, "err": err.Error()}).Errorf("Invalid actor type %T", it) 104 | return nil, err 105 | } 106 | 107 | u, _ := actor.ID.URL() 108 | if err = c.CreateOAuth2ClientIfMissing(actor.ID, pw); err != nil { 109 | c.Logger.WithContext(lw.Ctx{"host": u.Hostname(), "err": err.Error()}).Errorf("Unable to save OAuth2 Client") 110 | return nil, err 111 | } else { 112 | c.Logger.WithContext(lw.Ctx{"ClientID": actor.ID}).Infof("Created OAuth2 Client") 113 | } 114 | 115 | if withToken { 116 | clientID := u.Hostname() 117 | if tok, err := c.GenAccessToken(clientID, actor.ID.String(), nil); err == nil { 118 | c.Logger.Infof(" Authorization: Bearer %s", tok) 119 | } 120 | } 121 | if addr, err := checkIRIResolvesLocally(actor.ID); err != nil { 122 | c.Logger.WithContext(lw.Ctx{"err": err.Error(), "iri": actor.ID}).Warnf("Unable to resolve hostname to a valid address") 123 | c.Logger.Warnf("Please make sure you configure your network is configured correctly.") 124 | } else { 125 | c.Logger.WithContext(lw.Ctx{"iri": actor.ID, "addr": addr.String()}).Debugf("Successfully resolved hostname to a valid address") 126 | } 127 | 128 | return actor, nil 129 | } 130 | 131 | func (c *Control) GenAccessToken(clientID, actorIdentifier string, dat interface{}) (string, error) { 132 | if u, err := url.Parse(clientID); err == nil { 133 | clientID = filepath.Base(u.Path) 134 | if clientID == "." { 135 | clientID = u.Host 136 | } 137 | } 138 | cl, err := c.Storage.GetClient(clientID) 139 | if err != nil { 140 | return "", err 141 | } 142 | 143 | now := time.Now().UTC() 144 | var f processing.Filterable 145 | if u, err := url.Parse(actorIdentifier); err == nil { 146 | u.Scheme = "https" 147 | f = vocab.IRI(u.String()) 148 | } else { 149 | f = filters.FiltersNew(filters.Name(actorIdentifier), filters.Type(vocab.ActorTypes...)) 150 | } 151 | list, err := c.Storage.Load(f.GetLink()) 152 | if err != nil { 153 | return "", err 154 | } 155 | if vocab.IsNil(list) { 156 | return "", errors.NotFoundf("not found") 157 | } 158 | var actor vocab.Item 159 | if list.IsCollection() { 160 | err = vocab.OnCollectionIntf(list, func(c vocab.CollectionInterface) error { 161 | f := c.Collection().First() 162 | if f == nil { 163 | return errors.NotFoundf("no actor found %s", c.GetLink()) 164 | } 165 | actor, err = vocab.ToActor(f) 166 | return err 167 | }) 168 | } else { 169 | actor, err = vocab.ToActor(list) 170 | } 171 | if err != nil { 172 | return "", err 173 | } 174 | 175 | aud := &osin.AuthorizeData{ 176 | Client: cl, 177 | CreatedAt: now, 178 | ExpiresIn: 86400, 179 | RedirectUri: cl.GetRedirectUri(), 180 | State: "state", 181 | } 182 | 183 | // generate token code 184 | aud.Code, err = (&osin.AuthorizeTokenGenDefault{}).GenerateAuthorizeToken(aud) 185 | if err != nil { 186 | return "", err 187 | } 188 | 189 | // generate token directly 190 | ar := &osin.AccessRequest{ 191 | Type: osin.AUTHORIZATION_CODE, 192 | AuthorizeData: aud, 193 | Client: cl, 194 | RedirectUri: cl.GetRedirectUri(), 195 | Scope: "scope", 196 | Authorized: true, 197 | Expiration: -1, 198 | } 199 | 200 | ad := &osin.AccessData{ 201 | Client: ar.Client, 202 | AuthorizeData: ar.AuthorizeData, 203 | AccessData: ar.AccessData, 204 | ExpiresIn: ar.Expiration, 205 | Scope: ar.Scope, 206 | RedirectUri: cl.GetRedirectUri(), 207 | CreatedAt: now, 208 | UserData: actor.GetLink(), 209 | } 210 | 211 | // generate access token 212 | ad.AccessToken, ad.RefreshToken, err = (&osin.AccessTokenGenDefault{}).GenerateAccessToken(ad, ar.GenerateRefresh) 213 | if err != nil { 214 | return "", err 215 | } 216 | // save authorize data 217 | if err = c.Storage.SaveAuthorize(aud); err != nil { 218 | return "", err 219 | } 220 | // save access token 221 | if err = c.Storage.SaveAccess(ad); err != nil { 222 | return "", err 223 | } 224 | 225 | return ad.AccessToken, nil 226 | } 227 | -------------------------------------------------------------------------------- /assets.go: -------------------------------------------------------------------------------- 1 | package oni 2 | 3 | import "embed" 4 | 5 | //go:generate go run ./internal/esbuild/main.go 6 | 7 | //go:embed templates 8 | var TemplateFS embed.FS 9 | 10 | //go:embed static 11 | var AssetsFS embed.FS 12 | -------------------------------------------------------------------------------- /bin/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mariusor/oni/9a3f9d87b03205b06e2dc0448251b8f07503d129/bin/.gitkeep -------------------------------------------------------------------------------- /cmd/ctl/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/pem" 6 | "fmt" 7 | "net/url" 8 | "oni" 9 | "os" 10 | "path/filepath" 11 | "runtime/debug" 12 | "time" 13 | 14 | "git.sr.ht/~mariusor/lw" 15 | vocab "github.com/go-ap/activitypub" 16 | "github.com/go-ap/errors" 17 | "github.com/go-ap/processing" 18 | storage "github.com/go-ap/storage-fs" 19 | "github.com/urfave/cli/v2" 20 | ) 21 | 22 | type Control struct { 23 | oni.Control 24 | Service vocab.Actor 25 | StoragePath string 26 | } 27 | 28 | var tokenCmd = &cli.Command{ 29 | Name: "token", 30 | Usage: "OAuth2 authorization token management", 31 | Subcommands: []*cli.Command{tokenAddCmd}, 32 | } 33 | 34 | var tokenAddCmd = &cli.Command{ 35 | Name: "add", 36 | Aliases: []string{"new"}, 37 | Usage: "Adds an OAuth2 token", 38 | Flags: []cli.Flag{&cli.StringFlag{ 39 | Name: "client", 40 | Required: true, 41 | }}, 42 | Action: tokenAct(&ctl), 43 | } 44 | 45 | var OAuth2Cmd = &cli.Command{ 46 | Name: "oauth", 47 | Usage: "OAuth2 client and access token helper", 48 | Subcommands: []*cli.Command{tokenCmd}, 49 | } 50 | 51 | var fixCollectionsCmd = &cli.Command{ 52 | Name: "fix-collections", 53 | Usage: "", 54 | Action: fixCollectionsAct(&ctl), 55 | } 56 | 57 | var rotateKeyCmd = &cli.Command{ 58 | Name: "rotate-key", 59 | Usage: "Rotate the actors' private and public key pair", 60 | Action: rotateKey(&ctl), 61 | } 62 | 63 | var blockInstanceCmd = &cli.Command{ 64 | Name: "block", 65 | Usage: "Block instances", 66 | Flags: []cli.Flag{&cli.StringFlag{ 67 | Name: "client", 68 | Required: true, 69 | }}, 70 | Action: blockInstance(&ctl), 71 | } 72 | 73 | var ActorCmd = &cli.Command{ 74 | Name: "actor", 75 | Usage: "Actor helper", 76 | Subcommands: []*cli.Command{actorAddCmd, rotateKeyCmd}, 77 | } 78 | 79 | var actorAddCmd = &cli.Command{ 80 | Name: "add", 81 | Usage: "Add a new root actor", 82 | Flags: []cli.Flag{ 83 | &cli.BoolFlag{ 84 | Name: "with-token", 85 | Value: true, 86 | }, 87 | &cli.StringSliceFlag{Name: "pw"}, 88 | }, 89 | Action: addActorAct(&ctl), 90 | } 91 | 92 | func addActorAct(ctl *Control) cli.ActionFunc { 93 | return func(context *cli.Context) error { 94 | urls := context.Args().Slice() 95 | pws := context.StringSlice("pw") 96 | 97 | if context.NArg() == 0 { 98 | ctl.Logger.WithContext(lw.Ctx{"iri": oni.DefaultURL}).Warnf("No arguments received adding actor with default URL") 99 | urls = append(urls, oni.DefaultURL) 100 | } 101 | for i, maybeURL := range urls { 102 | if _, err := url.ParseRequestURI(maybeURL); err != nil { 103 | ctl.Logger.WithContext(lw.Ctx{"iri": maybeURL, "err": err.Error()}).Errorf("Received invalid URL") 104 | continue 105 | } 106 | 107 | pw := "" 108 | if i < len(pws)-1 { 109 | pw = pws[i] 110 | } 111 | 112 | if _, err := ctl.CreateActor(vocab.IRI(maybeURL), pw, context.Bool("with-token")); err != nil { 113 | ctl.Logger.WithContext(lw.Ctx{"iri": maybeURL, "err": err.Error()}).Errorf("Unable to create new Actor") 114 | } 115 | } 116 | return nil 117 | } 118 | } 119 | 120 | func dataPath() string { 121 | dh := os.Getenv("XDG_DATA_HOME") 122 | if dh == "" { 123 | if userPath := os.Getenv("HOME"); userPath == "" { 124 | dh = "/usr/share" 125 | } else { 126 | dh = filepath.Join(userPath, ".local/share") 127 | } 128 | } 129 | return filepath.Join(dh, "oni") 130 | } 131 | 132 | func newOrderedCollection(id vocab.IRI) *vocab.OrderedCollection { 133 | return &vocab.OrderedCollection{ 134 | ID: id, 135 | Type: vocab.OrderedCollectionType, 136 | Generator: ctl.Service.GetLink(), 137 | Published: time.Now().UTC(), 138 | } 139 | } 140 | 141 | func tryCreateCollection(storage oni.FullStorage, colIRI vocab.IRI) error { 142 | var collection *vocab.OrderedCollection 143 | items, err := ctl.Storage.Load(colIRI.GetLink()) 144 | if err != nil { 145 | if !errors.IsNotFound(err) { 146 | ctl.Logger.Errorf("Unable to load %s: %s", colIRI, err) 147 | return err 148 | } 149 | colSaver, ok := storage.(processing.CollectionStore) 150 | if !ok { 151 | return errors.Newf("Invalid storage type %T. Unable to handle collection operations.", storage) 152 | } 153 | it, err := colSaver.Create(newOrderedCollection(colIRI.GetLink())) 154 | if err != nil { 155 | ctl.Logger.Errorf("Unable to create collection %s: %s", colIRI, err) 156 | return err 157 | } 158 | collection, err = vocab.ToOrderedCollection(it) 159 | if err != nil { 160 | ctl.Logger.Errorf("Saved object is not a valid OrderedCollection, but %s: %s", it.GetType(), err) 161 | return err 162 | } 163 | } 164 | 165 | if vocab.IsNil(items) { 166 | return nil 167 | } 168 | 169 | if !items.IsCollection() { 170 | if _, err := storage.Save(items); err != nil { 171 | ctl.Logger.Errorf("Unable to save object %s: %s", items.GetLink(), err) 172 | return err 173 | } 174 | } 175 | collection, err = vocab.ToOrderedCollection(items) 176 | if err != nil { 177 | ctl.Logger.Errorf("Saved object is not a valid OrderedCollection, but %s: %s", items.GetType(), err) 178 | return err 179 | } 180 | _ = vocab.OnCollectionIntf(items, func(col vocab.CollectionInterface) error { 181 | collection.TotalItems = col.Count() 182 | for _, it := range col.Collection() { 183 | // Try saving objects in collection, which would create the collections if they exist 184 | if _, err := storage.Save(it); err != nil { 185 | ctl.Logger.Errorf("Unable to save object %s: %s", it.GetLink(), err) 186 | } 187 | } 188 | return nil 189 | }) 190 | 191 | collection.OrderedItems = nil 192 | _, err = storage.Save(collection) 193 | if err != nil { 194 | ctl.Logger.Errorf("Unable to save collection with updated totalItems", err) 195 | return err 196 | } 197 | 198 | return nil 199 | } 200 | 201 | func rotateKey(ctl *Control) cli.ActionFunc { 202 | printKey := func(u string) { 203 | pk, _ := ctl.Storage.LoadKey(vocab.IRI(u)) 204 | if pk != nil { 205 | pkEnc, _ := x509.MarshalPKCS8PrivateKey(pk) 206 | if pkEnc != nil { 207 | pkPem := pem.EncodeToMemory(&pem.Block{ 208 | Type: "PRIVATE KEY", 209 | Bytes: pkEnc, 210 | }) 211 | fmt.Printf("Private Key: %s\n", pkPem) 212 | } 213 | } 214 | } 215 | return func(context *cli.Context) error { 216 | urls := context.Args().Slice() 217 | 218 | if context.NArg() == 0 { 219 | ctl.Logger.WithContext(lw.Ctx{"iri": oni.DefaultURL}).Warnf("No arguments received adding actor with default URL") 220 | urls = append(urls, oni.DefaultURL) 221 | } 222 | for _, u := range urls { 223 | it, err := ctl.Storage.Load(vocab.IRI(u)) 224 | if err != nil { 225 | ctl.Logger.WithContext(lw.Ctx{"iri": u, "err": err.Error()}).Errorf("Invalid actor URL") 226 | continue 227 | } 228 | actor, err := vocab.ToActor(it) 229 | if err != nil { 230 | ctl.Logger.WithContext(lw.Ctx{"iri": u, "err": err.Error()}).Errorf("Invalid actor found for URL") 231 | continue 232 | } 233 | 234 | if actor, err = ctl.UpdateActorKey(actor); err != nil { 235 | ctl.Logger.WithContext(lw.Ctx{"iri": u, "err": err.Error()}).Errorf("Unable to update main Actor key") 236 | continue 237 | } 238 | printKey(u) 239 | } 240 | return nil 241 | } 242 | } 243 | 244 | func fixCollectionsAct(ctl *Control) cli.ActionFunc { 245 | return func(context *cli.Context) error { 246 | urls := context.Args().Slice() 247 | 248 | if context.NArg() == 0 { 249 | ctl.Logger.WithContext(lw.Ctx{"iri": oni.DefaultURL}).Warnf("No arguments received adding actor with default URL") 250 | urls = append(urls, oni.DefaultURL) 251 | } 252 | for _, u := range urls { 253 | it, err := ctl.Storage.Load(vocab.IRI(u)) 254 | if err != nil { 255 | ctl.Logger.WithContext(lw.Ctx{"iri": u, "err": err.Error()}).Errorf("Invalid actor URL") 256 | continue 257 | } 258 | actor, err := vocab.ToActor(it) 259 | if err != nil { 260 | ctl.Logger.WithContext(lw.Ctx{"iri": u, "err": err.Error()}).Errorf("Invalid actor found for URL") 261 | continue 262 | } 263 | _, err = ctl.Storage.Save(actor) 264 | if err != nil { 265 | ctl.Logger.WithContext(lw.Ctx{"iri": u, "err": err.Error()}).Errorf("Unable to save main Actor") 266 | continue 267 | } 268 | err = tryCreateCollection(ctl.Storage, actor.Outbox.GetLink()) 269 | if err != nil { 270 | ctl.Logger.WithContext(lw.Ctx{"iri": actor.ID, "err": err.Error()}).Errorf("Unable to save Outbox collection for main Actor") 271 | continue 272 | } 273 | } 274 | return nil 275 | } 276 | } 277 | 278 | func blockInstance(ctl *Control) cli.ActionFunc { 279 | return func(ctx *cli.Context) error { 280 | actorID := ctx.String("client") 281 | if actorID == "" { 282 | return errors.Newf("Need to provide the client id") 283 | } 284 | cl, err := ctl.Storage.Load(vocab.IRI(actorID)) 285 | if err != nil { 286 | return err 287 | } 288 | act, err := vocab.ToActor(cl) 289 | if err != nil { 290 | return errors.Annotatef(err, "unable to load actor from the client IRI") 291 | } 292 | ctl.Service = *act 293 | 294 | urls := ctx.Args() 295 | for _, u := range urls.Slice() { 296 | toBlock, _ := ctl.Storage.Load(vocab.IRI(u)) 297 | if vocab.IsNil(toBlock) { 298 | // NOTE(marius): if we don't have a local representation of the blocked item 299 | // we invent an empty object that we can block. 300 | // This probably needs more investigation to check if we should at least try to remote load. 301 | ctl.Logger.Warnf("Unable to load instance to block %s: %s", u, err) 302 | if toBlock, err = ctl.Storage.Save(vocab.Object{ID: vocab.IRI(u)}); err != nil { 303 | ctl.Logger.Warnf("Unable to save locally the instance to block %s: %s", u, err) 304 | } 305 | } 306 | 307 | blockedIRI := processing.BlockedCollection.IRI(ctl.Service) 308 | col, _ := ctl.Storage.Load(blockedIRI) 309 | if !vocab.IsObject(col) { 310 | col = vocab.OrderedCollection{ 311 | ID: blockedIRI, 312 | Type: vocab.OrderedCollectionType, 313 | To: vocab.ItemCollection{ctl.Service.ID}, 314 | Published: time.Now().UTC(), 315 | } 316 | 317 | if col, err = ctl.Storage.Save(col); err != nil { 318 | ctl.Logger.Warnf("Unable to save the blocked collection %s: %s", blockedIRI, err) 319 | } 320 | } 321 | if err := ctl.Storage.AddTo(blockedIRI, vocab.IRI(u)); err != nil { 322 | ctl.Logger.Warnf("Unable to block instance %s: %s", u, err) 323 | } 324 | } 325 | return nil 326 | } 327 | } 328 | 329 | func tokenAct(ctl *Control) cli.ActionFunc { 330 | return func(c *cli.Context) error { 331 | clientID := c.String("client") 332 | if clientID == "" { 333 | return errors.Newf("Need to provide the client id") 334 | } 335 | 336 | actor := clientID 337 | tok, err := ctl.GenAccessToken(clientID, actor, nil) 338 | if err == nil { 339 | fmt.Printf("Authorization: Bearer %s\n", tok) 340 | } 341 | return err 342 | } 343 | } 344 | 345 | var ctl Control 346 | 347 | func Before(c *cli.Context) error { 348 | storagePath := c.Path("path") 349 | fields := lw.Ctx{"path": storagePath} 350 | 351 | ll := lw.Dev().WithContext(fields) 352 | ctl.Logger = ll 353 | 354 | if err := mkDirIfNotExists(storagePath); err != nil { 355 | ll.WithContext(lw.Ctx{"err": err.Error()}).Errorf("Failed to create path") 356 | return err 357 | } 358 | 359 | conf := storage.Config{CacheEnable: true, Path: storagePath, Logger: ctl.Logger} 360 | st, err := storage.New(conf) 361 | if err != nil { 362 | ctl.Logger.WithContext(lw.Ctx{"err": err.Error()}).Errorf("Failed to initialize storage") 363 | return err 364 | } 365 | 366 | ctl.Storage = st 367 | if opener, ok := ctl.Storage.(interface{ Open() error }); ok { 368 | return opener.Open() 369 | } 370 | return nil 371 | } 372 | 373 | func After(c *cli.Context) error { 374 | defer ctl.Storage.Close() 375 | return nil 376 | } 377 | 378 | var version = "HEAD" 379 | 380 | func main() { 381 | app := cli.App{} 382 | app.Name = "onictl" 383 | app.Usage = "helper utility to manage an ONI instance" 384 | 385 | if build, ok := debug.ReadBuildInfo(); ok && version == "HEAD" { 386 | if build.Main.Version != "(devel)" { 387 | version = build.Main.Version 388 | } 389 | for _, bs := range build.Settings { 390 | if bs.Key == "vcs.revision" { 391 | version = bs.Value[:8] 392 | } 393 | if bs.Key == "vcs.modified" { 394 | version += "-git" 395 | } 396 | } 397 | } 398 | app.Version = version 399 | 400 | app.Before = Before 401 | app.After = After 402 | app.Flags = []cli.Flag{ 403 | &cli.PathFlag{ 404 | Name: "path", 405 | Value: dataPath(), 406 | }, 407 | } 408 | app.Commands = []*cli.Command{ActorCmd, OAuth2Cmd, fixCollectionsCmd, blockInstanceCmd} 409 | 410 | if err := app.Run(os.Args); err != nil { 411 | _, _ = fmt.Fprintln(os.Stderr, err) 412 | os.Exit(1) 413 | } 414 | } 415 | 416 | func mkDirIfNotExists(p string) (err error) { 417 | p, err = filepath.Abs(p) 418 | if err != nil { 419 | return err 420 | } 421 | fi, err := os.Stat(p) 422 | if err != nil && os.IsNotExist(err) { 423 | if err = os.MkdirAll(p, os.ModeDir|os.ModePerm|0700); err != nil { 424 | return err 425 | } 426 | fi, err = os.Stat(p) 427 | } 428 | if err != nil { 429 | return err 430 | } 431 | if !fi.IsDir() { 432 | return fmt.Errorf("path exists, and is not a folder %s", p) 433 | } 434 | return nil 435 | } 436 | -------------------------------------------------------------------------------- /cmd/oni/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "io/fs" 8 | "oni" 9 | "os" 10 | "path/filepath" 11 | "runtime/debug" 12 | "strings" 13 | 14 | "git.sr.ht/~mariusor/lw" 15 | vocab "github.com/go-ap/activitypub" 16 | ) 17 | 18 | var dataPath = func() string { 19 | dh := os.Getenv("XDG_DATA_HOME") 20 | if dh == "" { 21 | if userPath := os.Getenv("HOME"); userPath == "" { 22 | dh = "/usr/share" 23 | } else { 24 | dh = filepath.Join(userPath, ".local/share") 25 | } 26 | } 27 | return filepath.Join(dh, "oni") 28 | }() 29 | 30 | func loadAccountsFromStorage(base string) (vocab.ItemCollection, error) { 31 | urls := make(vocab.ItemCollection, 0) 32 | err := filepath.WalkDir(base, func(file string, d fs.DirEntry, err error) error { 33 | if maybeActor, ok := maybeLoadServiceActor(path, file); ok { 34 | urls = append(urls, maybeActor) 35 | } 36 | return nil 37 | }) 38 | return urls, err 39 | } 40 | 41 | func maybeLoadServiceActor(base, path string) (*vocab.Actor, bool) { 42 | if base[len(base)-1] != '/' { 43 | base = base + "/" 44 | } 45 | pieces := strings.Split(strings.Replace(path, base, "", 1), string(filepath.Separator)) 46 | if len(pieces) == 2 && pieces[1] == "__raw" { 47 | raw, err := os.ReadFile(path) 48 | if err != nil { 49 | return nil, false 50 | } 51 | it, err := vocab.UnmarshalJSON(raw) 52 | if err != nil || vocab.IsNil(it) { 53 | return nil, false 54 | } 55 | act, err := vocab.ToActor(it) 56 | if err != nil { 57 | return nil, false 58 | } 59 | return act, true 60 | } 61 | return nil, false 62 | } 63 | 64 | var ( 65 | version = "HEAD" 66 | 67 | listen string 68 | path string 69 | verbose bool 70 | ) 71 | 72 | func main() { 73 | flag.StringVar(&listen, "listen", "127.0.0.1:60123", "Listen socket") 74 | flag.StringVar(&path, "path", dataPath, "Path for ActivityPub storage") 75 | flag.BoolVar(&verbose, "verbose", false, "Show verbose ll output") 76 | flag.Parse() 77 | 78 | if build, ok := debug.ReadBuildInfo(); ok && version == "HEAD" { 79 | if build.Main.Version != "(devel)" { 80 | version = build.Main.Version 81 | } 82 | for _, bs := range build.Settings { 83 | if bs.Key == "vcs.revision" { 84 | version = bs.Value[:8] 85 | } 86 | if bs.Key == "vcs.modified" { 87 | version += "-git" 88 | } 89 | } 90 | } 91 | 92 | oni.Version = version 93 | lvl := lw.DebugLevel 94 | if verbose { 95 | lvl = lw.TraceLevel 96 | } 97 | 98 | ll := lw.Dev(lw.SetLevel(lvl)) 99 | 100 | err := mkDirIfNotExists(path) 101 | if err != nil { 102 | ll.WithContext(lw.Ctx{"err": err.Error()}).Errorf("Failed to create path") 103 | os.Exit(1) 104 | } 105 | 106 | urls, err := loadAccountsFromStorage(path) 107 | if err != nil { 108 | ll.WithContext(lw.Ctx{"err": err.Error()}).Errorf("Failed to load accounts from storage") 109 | os.Exit(1) 110 | } 111 | 112 | err = oni.Oni( 113 | oni.WithLogger(ll), 114 | oni.WithStoragePath(path), 115 | oni.LoadActor(urls...), 116 | oni.ListenOn(listen), 117 | ).Run(context.Background()) 118 | if err != nil { 119 | ll.WithContext(lw.Ctx{"err": err.Error()}).Errorf("Failed to start server") 120 | os.Exit(1) 121 | } 122 | } 123 | 124 | func mkDirIfNotExists(p string) (err error) { 125 | p, err = filepath.Abs(p) 126 | if err != nil { 127 | return err 128 | } 129 | fi, err := os.Stat(p) 130 | if err != nil && os.IsNotExist(err) { 131 | if err = os.MkdirAll(p, os.ModeDir|os.ModePerm|0700); err != nil { 132 | return err 133 | } 134 | fi, err = os.Stat(p) 135 | } 136 | if err != nil { 137 | return err 138 | } 139 | if !fi.IsDir() { 140 | return fmt.Errorf("path exists, and is not a folder %s", p) 141 | } 142 | return nil 143 | } 144 | -------------------------------------------------------------------------------- /dev.go: -------------------------------------------------------------------------------- 1 | //go:build dev 2 | 3 | package oni 4 | 5 | var IsDev = true 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module oni 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | git.sr.ht/~mariusor/cache v0.0.0-20250122165545-14c90d7a9de8 7 | git.sr.ht/~mariusor/lw v0.0.0-20250325163623-1639f3fb0e0d 8 | git.sr.ht/~mariusor/ssm v0.0.0-20250423085606-bbcdeae30fce 9 | git.sr.ht/~mariusor/wrapper v0.0.0-20250504120759-5fa47ac25e08 10 | github.com/elnormous/contenttype v1.0.4 11 | github.com/evanw/esbuild v0.25.1 12 | github.com/go-ap/activitypub v0.0.0-20250527110644-1410ed93404d 13 | github.com/go-ap/auth v0.0.0-20250527112020-6b6fbf0ccd0a 14 | github.com/go-ap/client v0.0.0-20250527111551-a90c7d58948f 15 | github.com/go-ap/errors v0.0.0-20250527110557-c8db454e53fd 16 | github.com/go-ap/filters v0.0.0-20250527111509-8ef063ce5449 17 | github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 18 | github.com/go-ap/processing v0.0.0-20250527112052-3ec998803b27 19 | github.com/go-ap/storage-fs v0.0.0-20250527112321-492ff876f7e8 20 | github.com/go-chi/chi/v5 v5.2.1 21 | github.com/go-chi/cors v1.2.1 22 | github.com/google/uuid v1.6.0 23 | github.com/mariusor/render v1.5.1-0.20221026090743-ab78c1b3aa95 24 | github.com/microcosm-cc/bluemonday v1.0.27 25 | github.com/openshift/osin v1.0.2-0.20220317075346-0f4d38c6e53f 26 | github.com/urfave/cli/v2 v2.27.5 27 | github.com/valyala/fastjson v1.6.4 28 | golang.org/x/oauth2 v0.30.0 29 | ) 30 | 31 | require ( 32 | git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect 33 | git.sr.ht/~mariusor/mask v0.0.0-20250114195353-98705a6977b7 // indirect 34 | github.com/RoaringBitmap/roaring v1.9.4 // indirect 35 | github.com/aymerick/douceur v0.2.0 // indirect 36 | github.com/bits-and-blooms/bitset v1.22.0 // indirect 37 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 38 | github.com/go-ap/cache v0.0.0-20250527110731-01dd30d088be // indirect 39 | github.com/go-fed/httpsig v1.1.0 // indirect 40 | github.com/gorilla/css v1.0.1 // indirect 41 | github.com/jdkato/prose v1.2.1 // indirect 42 | github.com/mariusor/qstring v0.0.0-20200204164351-5a99d46de39d // indirect 43 | github.com/mattn/go-colorable v0.1.14 // indirect 44 | github.com/mattn/go-isatty v0.0.20 // indirect 45 | github.com/mschoch/smat v0.2.0 // indirect 46 | github.com/pborman/uuid v1.2.1 // indirect 47 | github.com/rs/xid v1.6.0 // indirect 48 | github.com/rs/zerolog v1.34.0 // indirect 49 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 50 | github.com/spaolacci/murmur3 v1.1.0 // indirect 51 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 52 | golang.org/x/crypto v0.38.0 // indirect 53 | golang.org/x/net v0.40.0 // indirect 54 | golang.org/x/sys v0.33.0 // indirect 55 | golang.org/x/text v0.25.0 // indirect 56 | gopkg.in/neurosnap/sentences.v1 v1.0.7 // indirect 57 | ) 58 | -------------------------------------------------------------------------------- /handlers.go: -------------------------------------------------------------------------------- 1 | package oni 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/md5" 7 | "fmt" 8 | "html/template" 9 | "io" 10 | "io/fs" 11 | "net/http" 12 | "os" 13 | "path/filepath" 14 | "strconv" 15 | "strings" 16 | "time" 17 | 18 | "git.sr.ht/~mariusor/cache" 19 | "git.sr.ht/~mariusor/lw" 20 | "git.sr.ht/~mariusor/ssm" 21 | ct "github.com/elnormous/contenttype" 22 | vocab "github.com/go-ap/activitypub" 23 | "github.com/go-ap/auth" 24 | "github.com/go-ap/client" 25 | "github.com/go-ap/client/s2s" 26 | "github.com/go-ap/errors" 27 | "github.com/go-ap/filters" 28 | json "github.com/go-ap/jsonld" 29 | "github.com/go-ap/processing" 30 | "github.com/go-chi/chi/v5" 31 | "github.com/go-chi/chi/v5/middleware" 32 | "github.com/go-chi/cors" 33 | "github.com/mariusor/render" 34 | "github.com/microcosm-cc/bluemonday" 35 | ) 36 | 37 | // NotFound is a generic method to return an 404 error HTTP handler that 38 | func (o *oni) NotFound(w http.ResponseWriter, r *http.Request) { 39 | o.Error(errors.NotFoundf("%s not found", r.URL.Path)).ServeHTTP(w, r) 40 | } 41 | 42 | func (o *oni) Error(err error) http.HandlerFunc { 43 | o.Logger.WithContext(lw.Ctx{"err": err.Error()}).Errorf("Rendering error") 44 | return func(w http.ResponseWriter, r *http.Request) { 45 | acceptableMediaTypes := []ct.MediaType{textHTML, applicationJson} 46 | accepted, _, _ := ct.GetAcceptableMediaType(r, acceptableMediaTypes) 47 | if !checkAcceptMediaType(accepted)(textHTML) || errors.IsRedirect(err) { 48 | errors.HandleError(err).ServeHTTP(w, r) 49 | return 50 | } 51 | errs := errors.HttpErrors(err) 52 | status := errors.HttpStatus(err) 53 | if status == 0 { 54 | status = http.StatusInternalServerError 55 | } 56 | oniFn := template.FuncMap{ 57 | "ONI": func() vocab.Actor { return o.oniActor(r) }, 58 | "URLS": actorURLs(o.oniActor(r)), 59 | "Title": func() string { return http.StatusText(errors.HttpStatus(err)) }, 60 | "CurrentURL": func() template.HTMLAttr { return "" }, 61 | } 62 | templatePath := "components/errors" 63 | wrt := bytes.Buffer{} 64 | if err = ren.HTML(&wrt, status, templatePath, errs, render.HTMLOptions{Funcs: oniFn}); err != nil { 65 | errors.HandleError(err).ServeHTTP(w, r) 66 | return 67 | } 68 | w.WriteHeader(status) 69 | _, _ = io.Copy(w, &wrt) 70 | } 71 | } 72 | 73 | func (o *oni) setupOauthRoutes(m chi.Router) { 74 | m.HandleFunc("/oauth/authorize", o.Authorize) 75 | m.HandleFunc("/oauth/token", o.Token) 76 | m.HandleFunc("/oauth/client", HandleOauthClientRegistration(*o)) 77 | } 78 | 79 | func (o *oni) setupRoutes(actors []vocab.Actor) { 80 | m := chi.NewMux() 81 | 82 | if len(actors) == 0 { 83 | m.HandleFunc("/", o.NotFound) 84 | return 85 | } 86 | m.Use(o.OutOfOrderMw) 87 | m.Use(Log(o.Logger)) 88 | 89 | o.setupActivityPubRoutes(m) 90 | o.setupOauthRoutes(m) 91 | o.setupStaticRoutes(m) 92 | o.setupWebfingerRoutes(m) 93 | 94 | m.Mount("/debug", middleware.Profiler()) 95 | 96 | o.m = m 97 | } 98 | 99 | func (o *oni) setupStaticRoutes(m chi.Router) { 100 | var fsServe http.HandlerFunc 101 | if assetFilesFS, err := fs.Sub(AssetsFS, "static"); err == nil { 102 | fsServe = func(w http.ResponseWriter, r *http.Request) { 103 | http.FileServer(http.FS(assetFilesFS)).ServeHTTP(w, r) 104 | } 105 | } else { 106 | fsServe = o.Error(err).ServeHTTP 107 | } 108 | m.Handle("/main.js", fsServe) 109 | m.Handle("/main.js.map", fsServe) 110 | m.Handle("/main.css", fsServe) 111 | m.HandleFunc("/icons.svg", fsServe) 112 | m.HandleFunc("/robots.txt", fsServe) 113 | m.HandleFunc("/favicon.ico", o.NotFound) 114 | } 115 | 116 | func (o *oni) setupWebfingerRoutes(m chi.Router) { 117 | // TODO(marius): we need the nodeinfo handlers also 118 | m.HandleFunc("/.well-known/webfinger", HandleWebFinger(*o)) 119 | m.HandleFunc("/.well-known/host-meta", HandleHostMeta(*o)) 120 | m.HandleFunc("/.well-known/oauth-authorization-server", HandleOauthAuthorizationServer(*o)) 121 | } 122 | 123 | type corsLogger func(string, ...any) 124 | 125 | func (c corsLogger) Printf(f string, v ...interface{}) { 126 | c(f, v...) 127 | } 128 | 129 | func (o *oni) setupActivityPubRoutes(m chi.Router) { 130 | c := cors.New(cors.Options{ 131 | AllowedOrigins: []string{"https://*"}, 132 | AllowedMethods: []string{"GET", "POST", "OPTIONS"}, 133 | AllowedHeaders: []string{"*"}, 134 | AllowCredentials: true, 135 | AllowOriginFunc: checkOriginForBlockedActors, 136 | MaxAge: 300, // Maximum value not ignored by any of major browsers 137 | Debug: IsDev, 138 | }) 139 | c.Log = corsLogger(o.Logger.WithContext(lw.Ctx{"log": "cors"}).Tracef) 140 | m.Group(func(m chi.Router) { 141 | m.Use(c.Handler, o.StopBlocked) 142 | m.HandleFunc("/*", o.ActivityPubItem) 143 | }) 144 | } 145 | 146 | func (o *oni) ServeBinData(it vocab.Item) http.HandlerFunc { 147 | if vocab.IsNil(it) { 148 | return o.Error(errors.NotFoundf("not found")) 149 | } 150 | var contentType string 151 | var raw []byte 152 | err := vocab.OnObject(it, func(ob *vocab.Object) error { 153 | var err error 154 | if !mediaTypes.Contains(ob.Type) { 155 | return errors.NotSupportedf("invalid object") 156 | } 157 | if len(ob.Content) == 0 { 158 | return errors.NotSupportedf("invalid object") 159 | } 160 | if contentType, raw, err = getBinData(ob.Content, ob.MediaType); err != nil { 161 | return err 162 | } 163 | return nil 164 | }) 165 | eTag := md5.Sum(raw) 166 | if err != nil { 167 | return o.Error(err) 168 | } 169 | return func(w http.ResponseWriter, r *http.Request) { 170 | w.Header().Set("Content-Type", contentType) 171 | w.Header().Set("Content-Length", fmt.Sprintf("%d", len(raw))) 172 | w.Header().Set("Vary", "Accept") 173 | w.Header().Set("ETag", fmt.Sprintf(`"%2x"`, eTag)) 174 | _, _ = w.Write(raw) 175 | } 176 | } 177 | 178 | func sameishIRI(check, colIRI vocab.IRI) bool { 179 | uc, _ := check.GetID().URL() 180 | ui, _ := colIRI.URL() 181 | uc.RawQuery = "" 182 | ui.RawQuery = "" 183 | if uc.Path == "/" { 184 | uc.Path = "" 185 | } 186 | if ui.Path == "/" { 187 | ui.Path = "" 188 | } 189 | return strings.EqualFold(uc.String(), ui.String()) 190 | } 191 | 192 | var orderedCollectionTypes = vocab.ActivityVocabularyTypes{ 193 | vocab.OrderedCollectionPageType, vocab.OrderedCollectionType, 194 | } 195 | 196 | func loadItemFromStorage(s processing.ReadStore, iri vocab.IRI, f ...filters.Check) (vocab.Item, error) { 197 | var isObjProperty bool 198 | var prop string 199 | 200 | it, err := s.Load(iri, f...) 201 | if err != nil { 202 | if !errors.IsNotFound(err) { 203 | return nil, err 204 | } 205 | 206 | if isObjProperty, prop = propNameInIRI(iri); isObjProperty { 207 | it, err = s.Load(vocab.IRI(strings.TrimSuffix(string(iri), prop)), f...) 208 | if err != nil { 209 | return nil, err 210 | } 211 | } 212 | } 213 | 214 | if vocab.IsNil(it) { 215 | return nil, errors.NotFoundf("not found") 216 | } 217 | 218 | typ := it.GetType() 219 | switch { 220 | case orderedCollectionTypes.Contains(typ): 221 | _ = vocab.OnOrderedCollection(it, func(col *vocab.OrderedCollection) error { 222 | filtered := make(vocab.ItemCollection, 0, len(col.OrderedItems)) 223 | for _, ob := range col.OrderedItems { 224 | if ob = filters.Checks(f).Run(ob); !vocab.IsNil(ob) { 225 | filtered = append(filtered, ob) 226 | } else { 227 | ob = nil 228 | } 229 | } 230 | col.OrderedItems = filtered 231 | return nil 232 | }) 233 | } 234 | 235 | if sameishIRI(it.GetLink(), iri) { 236 | return it, nil 237 | } 238 | 239 | if vocab.ActivityTypes.Contains(typ) { 240 | err = vocab.OnActivity(it, func(act *vocab.Activity) error { 241 | switch prop { 242 | case "object": 243 | it = act.Object 244 | case "actor": 245 | it = act.Actor 246 | case "target": 247 | it = act.Target 248 | default: 249 | return iriNotFound(it.GetLink()) 250 | } 251 | return nil 252 | }) 253 | } else { 254 | err = vocab.OnObject(it, func(ob *vocab.Object) error { 255 | switch prop { 256 | case "icon": 257 | it = ob.Icon 258 | case "image": 259 | it = ob.Image 260 | case "attachment": 261 | it = ob.Attachment 262 | default: 263 | return iriNotFound(it.GetLink()) 264 | } 265 | return nil 266 | }) 267 | } 268 | if vocab.IsNil(it) { 269 | return nil, errors.NotFoundf("not found") 270 | } 271 | if vocab.IsIRI(it) && !it.GetLink().Equals(iri, true) { 272 | it, err = loadItemFromStorage(s, it.GetLink()) 273 | } 274 | if !vocab.IsItemCollection(it) { 275 | if err != nil { 276 | return it, errors.NewMovedPermanently(err, it.GetLink().String()) 277 | } 278 | propIRI := it.GetLink() 279 | if propIRI != "" { 280 | return it, errors.MovedPermanently(it.GetLink().String()) 281 | } else { 282 | return it, nil 283 | } 284 | } 285 | return it, err 286 | } 287 | 288 | var propertiesThatMightBeObjects = []string{"object", "actor", "target", "icon", "image", "attachment"} 289 | 290 | func propNameInIRI(iri vocab.IRI) (bool, string) { 291 | u, _ := iri.URL() 292 | base := filepath.Base(u.Path) 293 | for _, prop := range propertiesThatMightBeObjects { 294 | if strings.EqualFold(prop, base) { 295 | return true, prop 296 | } 297 | } 298 | return false, "" 299 | } 300 | 301 | var mediaTypes = vocab.ActivityVocabularyTypes{ 302 | vocab.ImageType, vocab.AudioType, vocab.VideoType, vocab.DocumentType, 303 | } 304 | 305 | func cleanupMediaObjectFromItem(it vocab.Item) error { 306 | if it == nil { 307 | return nil 308 | } 309 | if it.IsCollection() { 310 | return vocab.OnCollectionIntf(it, cleanupMediaObjectsFromCollection) 311 | } 312 | if vocab.ActivityTypes.Contains(it.GetType()) { 313 | return vocab.OnActivity(it, cleanupMediaObjectFromActivity) 314 | } 315 | return vocab.OnObject(it, cleanupMediaObject) 316 | } 317 | 318 | func cleanupMediaObjectsFromCollection(col vocab.CollectionInterface) error { 319 | errs := make([]error, 0) 320 | for _, it := range col.Collection() { 321 | if err := cleanupMediaObjectFromItem(it); err != nil { 322 | errs = append(errs, err) 323 | } 324 | } 325 | return errors.Join(errs...) 326 | } 327 | 328 | func cleanupMediaObjectFromActivity(act *vocab.Activity) error { 329 | if err := cleanupMediaObjectFromItem(act.Object); err != nil { 330 | return err 331 | } 332 | if err := cleanupMediaObjectFromItem(act.Target); err != nil { 333 | return err 334 | } 335 | return nil 336 | } 337 | 338 | func contentHasBinaryData(nlv vocab.NaturalLanguageValues) bool { 339 | for _, nv := range nlv { 340 | if bytes.HasPrefix(nv.Value, []byte("data:")) { 341 | return true 342 | } 343 | } 344 | return false 345 | } 346 | 347 | func cleanupMediaObject(o *vocab.Object) error { 348 | if contentHasBinaryData(o.Content) { 349 | // NOTE(marius): remove inline content from media ActivityPub objects 350 | o.Content = o.Content[:0] 351 | if o.URL == nil { 352 | // Add an explicit URL if missing. 353 | o.URL = o.ID 354 | } 355 | } 356 | return cleanupMediaObjectFromItem(o.Attachment) 357 | } 358 | 359 | func (o *oni) ServeActivityPubItem(it vocab.Item) http.HandlerFunc { 360 | _ = cleanupMediaObjectFromItem(it) 361 | 362 | dat, err := json.WithContext(json.IRI(vocab.ActivityBaseURI), json.IRI(vocab.SecurityContextURI)).Marshal(it) 363 | if err != nil { 364 | return o.Error(err) 365 | } 366 | 367 | eTag := md5.Sum(dat) 368 | return func(w http.ResponseWriter, r *http.Request) { 369 | _ = vocab.OnObject(it, func(o *vocab.Object) error { 370 | if vocab.ActivityTypes.Contains(o.Type) { 371 | w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", int(8766*time.Hour.Seconds()))) 372 | } 373 | return nil 374 | }) 375 | status := http.StatusOK 376 | if it.GetType() == vocab.TombstoneType { 377 | status = http.StatusGone 378 | } 379 | w.Header().Set("Content-Type", json.ContentType) 380 | w.Header().Set("Vary", "Accept") 381 | w.Header().Set("ETag", fmt.Sprintf(`"%2x"`, eTag)) 382 | w.WriteHeader(status) 383 | if r.Method == http.MethodGet { 384 | _, _ = w.Write(dat) 385 | } 386 | } 387 | } 388 | 389 | func notAcceptable(err error) func(receivedIn vocab.IRI, r *http.Request) (vocab.Item, int, error) { 390 | return func(receivedIn vocab.IRI, r *http.Request) (vocab.Item, int, error) { 391 | return nil, http.StatusNotAcceptable, errors.NewMethodNotAllowed(err, "current instance does not federate") 392 | } 393 | } 394 | 395 | func validContentType(c string) bool { 396 | if c == client.ContentTypeActivityJson || c == client.ContentTypeJsonLD { 397 | return true 398 | } 399 | 400 | return false 401 | } 402 | 403 | var validActivityCollections = vocab.CollectionPaths{vocab.Outbox, vocab.Inbox} 404 | 405 | func validActivityCollection(r *http.Request) bool { 406 | return validActivityCollections.Contains(processing.Typer.Type(r)) 407 | } 408 | 409 | func (o *oni) ValidateRequest(r *http.Request) (bool, error) { 410 | contType := r.Header.Get("Content-Type") 411 | if r.Method != http.MethodPost { 412 | return false, errors.MethodNotAllowedf("invalid HTTP method") 413 | } 414 | if !validContentType(contType) { 415 | return false, errors.NotValidf("invalid content type") 416 | } 417 | if !validActivityCollection(r) { 418 | return false, errors.NotValidf("invalid collection") 419 | } 420 | 421 | baseIRIs := make(vocab.IRIs, 0) 422 | for _, act := range o.a { 423 | _ = baseIRIs.Append(act.GetID()) 424 | } 425 | 426 | isLocalIRI := func(iri vocab.IRI) bool { 427 | return baseIRIs.Contains(iri) 428 | } 429 | 430 | var logFn auth.LoggerFn = func(ctx lw.Ctx, msg string, p ...interface{}) { 431 | o.Logger.WithContext(lw.Ctx{"log": "auth"}, ctx).Debugf(msg, p...) 432 | } 433 | 434 | author := auth.AnonymousActor 435 | if loaded, ok := r.Context().Value(authorizedActorCtxKey).(vocab.Actor); ok { 436 | author = loaded 437 | } else { 438 | solver := auth.ClientResolver(Client(auth.AnonymousActor, o.Storage, o.Logger.WithContext(lw.Ctx{"log": "keyfetch"})), 439 | auth.SolverWithStorage(o.Storage), auth.SolverWithLogger(logFn), 440 | auth.SolverWithLocalIRIFn(isLocalIRI), 441 | ) 442 | 443 | author, _ = solver.LoadActorFromRequest(r) 444 | *r = *r.WithContext(context.WithValue(r.Context(), authorizedActorCtxKey, author)) 445 | } 446 | 447 | if auth.AnonymousActor.ID.Equals(author.ID, true) { 448 | return false, errors.Unauthorizedf("authorized Actor is invalid") 449 | } 450 | 451 | return true, nil 452 | } 453 | 454 | var jsonLD, _ = ct.ParseMediaType(fmt.Sprintf("%s;q=0.8", client.ContentTypeJsonLD)) 455 | var activityJson, _ = ct.ParseMediaType(fmt.Sprintf("%s;q=0.8", client.ContentTypeActivityJson)) 456 | var applicationJson, _ = ct.ParseMediaType("application/json;q=0.8") 457 | var textHTML, _ = ct.ParseMediaType("text/html;q=1.0") 458 | var imageAny, _ = ct.ParseMediaType("image/*;q=1.0") 459 | var audioAny, _ = ct.ParseMediaType("audio/*;q=1.0") 460 | var videoAny, _ = ct.ParseMediaType("video/*;q=1.0") 461 | var pdfDocument, _ = ct.ParseMediaType("application/pdf;q=1.0") 462 | 463 | func getWeight(m ct.MediaType) int { 464 | q, ok := m.Parameters["q"] 465 | if !ok { 466 | return 0 467 | } 468 | w, err := strconv.ParseFloat(q, 32) 469 | if err != nil { 470 | return 0 471 | } 472 | return int(w * 1000) 473 | } 474 | 475 | func checkAcceptMediaType(accepted ct.MediaType) func(check ...ct.MediaType) bool { 476 | return func(check ...ct.MediaType) bool { 477 | for _, c := range check { 478 | if accepted.Type == c.Type && (c.Subtype == "*" || accepted.Subtype == c.Subtype) { 479 | return getWeight(c) >= getWeight(accepted) 480 | } 481 | } 482 | return false 483 | } 484 | } 485 | 486 | var iriNotFound = func(iri vocab.IRI) error { 487 | return errors.NotFoundf("%s not found", iri) 488 | } 489 | 490 | func getRequestAcceptedContentType(r *http.Request) func(...ct.MediaType) bool { 491 | acceptableMediaTypes := []ct.MediaType{textHTML, jsonLD, activityJson, applicationJson} 492 | accepted, _, _ := ct.GetAcceptableMediaType(r, acceptableMediaTypes) 493 | return checkAcceptMediaType(accepted) 494 | } 495 | 496 | func getItemAcceptedContentType(it vocab.Item, r *http.Request) func(check ...ct.MediaType) bool { 497 | acceptableMediaTypes := make([]ct.MediaType, 0) 498 | 499 | _ = vocab.OnObject(it, func(ob *vocab.Object) error { 500 | if ob.MediaType != "" { 501 | if mt, err := ct.ParseMediaType(string(ob.MediaType)); err == nil { 502 | mt.Parameters["q"] = "1.0" 503 | acceptableMediaTypes = append([]ct.MediaType{mt}, acceptableMediaTypes...) 504 | } 505 | } else { 506 | acceptableMediaTypes = append(acceptableMediaTypes, textHTML) 507 | } 508 | return nil 509 | }) 510 | acceptableMediaTypes = append(acceptableMediaTypes, jsonLD, activityJson, applicationJson) 511 | 512 | accepted, _, _ := ct.GetAcceptableMediaType(r, acceptableMediaTypes) 513 | if accepted.Type == "" { 514 | accepted = textHTML 515 | } 516 | return checkAcceptMediaType(accepted) 517 | } 518 | 519 | func actorURLs(act vocab.Actor) func() vocab.IRIs { 520 | urls := make(vocab.IRIs, 0) 521 | if vocab.IsItemCollection(act.URL) { 522 | _ = vocab.OnItemCollection(act.URL, func(col *vocab.ItemCollection) error { 523 | for _, u := range *col { 524 | _ = urls.Append(u.GetLink()) 525 | } 526 | return nil 527 | }) 528 | } else if !vocab.IsNil(act.URL) { 529 | _ = urls.Append(act.URL.GetLink()) 530 | } 531 | return func() vocab.IRIs { 532 | return urls 533 | } 534 | } 535 | 536 | var validActivityTypes = vocab.ActivityVocabularyTypes{ 537 | vocab.CreateType, vocab.UpdateType, 538 | } 539 | 540 | var validObjectTypes = vocab.ActivityVocabularyTypes{ 541 | vocab.NoteType, vocab.ArticleType, vocab.ImageType, vocab.AudioType, vocab.VideoType, 542 | vocab.EventType, /*vocab.DocumentType, vocab.CollectionOfItems,*/ 543 | } 544 | 545 | func filtersCreateUpdate(ff filters.Checks) bool { 546 | for _, vv := range filters.ToValues(filters.TypeChecks(ff...)...) { 547 | for _, v := range vv { 548 | t := vocab.ActivityVocabularyType(v) 549 | if validActivityTypes.Contains(t) { 550 | return true 551 | } 552 | } 553 | } 554 | return false 555 | } 556 | 557 | func iriHasObjectTypeFilter(iri vocab.IRI) bool { 558 | u, err := iri.URL() 559 | if err != nil { 560 | return false 561 | } 562 | return u.Query().Has("object.type") 563 | } 564 | 565 | func (o *oni) ServeHTML(it vocab.Item) http.HandlerFunc { 566 | templatePath := "components/person" 567 | if !vocab.ActorTypes.Contains(it.GetType()) { 568 | templatePath = "components/item" 569 | } 570 | 571 | _ = cleanupMediaObjectFromItem(it) 572 | return func(w http.ResponseWriter, r *http.Request) { 573 | oniActor := o.oniActor(r) 574 | oniFn := template.FuncMap{ 575 | "ONI": func() vocab.Actor { return oniActor }, 576 | "URLS": actorURLs(oniActor), 577 | "Title": titleFromItem(it, r), 578 | "CurrentURL": func() template.HTMLAttr { 579 | return template.HTMLAttr(fmt.Sprintf("https://%s%s", r.Host, r.RequestURI)) 580 | }, 581 | } 582 | wrt := bytes.Buffer{} 583 | if err := ren.HTML(&wrt, http.StatusOK, templatePath, it, render.HTMLOptions{Funcs: oniFn}); err != nil { 584 | o.Logger.Errorf("Unable to render %s: %s", templatePath, err) 585 | o.Error(err).ServeHTTP(w, r) 586 | return 587 | } 588 | eTag := md5.Sum(wrt.Bytes()) 589 | w.Header().Set("Vary", "Accept") 590 | w.Header().Set("ETag", fmt.Sprintf(`"%2x"`, eTag)) 591 | _, _ = io.Copy(w, &wrt) 592 | } 593 | } 594 | 595 | const authorizedActorCtxKey = "__authorizedActor" 596 | const blockedActorsCtxKey = "__blockedActors" 597 | 598 | func (o oni) loadAuthorizedActor(r *http.Request, oniActor vocab.Actor, toIgnore ...vocab.IRI) (vocab.Actor, error) { 599 | if act, ok := r.Context().Value(authorizedActorCtxKey).(vocab.Actor); ok { 600 | return act, nil 601 | } 602 | 603 | c := Client(auth.AnonymousActor, o.Storage, o.Logger) 604 | s, err := auth.New( 605 | auth.WithIRI(oniActor.GetLink()), 606 | auth.WithStorage(o.Storage), 607 | auth.WithClient(c), 608 | auth.WithLogger(o.Logger.WithContext(lw.Ctx{"log": "osin"})), 609 | ) 610 | if err != nil { 611 | return auth.AnonymousActor, errors.Errorf("OAuth server not initialized") 612 | } 613 | return s.LoadActorFromRequest(r, toIgnore...) 614 | } 615 | 616 | func checkOriginForBlockedActors(r *http.Request, origin string) bool { 617 | blocked, ok := r.Context().Value(blockedActorsCtxKey).(vocab.IRIs) 618 | if ok { 619 | oIRI := vocab.IRI(origin) 620 | for _, b := range blocked { 621 | if b.Contains(oIRI, false) { 622 | return false 623 | } 624 | } 625 | } 626 | return true 627 | } 628 | 629 | func (o *oni) loadBlockedActors(of vocab.Item) vocab.IRIs { 630 | var blocked vocab.IRIs 631 | if res, err := o.Storage.Load(processing.BlockedCollection.IRI(of)); err == nil { 632 | _ = vocab.OnCollectionIntf(res, func(col vocab.CollectionInterface) error { 633 | blocked = col.Collection().IRIs() 634 | return nil 635 | }) 636 | } 637 | return blocked 638 | } 639 | 640 | func (o *oni) StopBlocked(next http.Handler) http.Handler { 641 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 642 | oniActor := o.oniActor(r) 643 | 644 | blocked := o.loadBlockedActors(oniActor) 645 | act, _ := o.loadAuthorizedActor(r, auth.AnonymousActor, blocked...) 646 | ctx := r.Context() 647 | ctx = context.WithValue(ctx, authorizedActorCtxKey, act) 648 | ctx = context.WithValue(ctx, blockedActorsCtxKey, blocked) 649 | r = r.WithContext(ctx) 650 | if len(blocked) > 0 { 651 | if !vocab.PublicNS.Equals(act.ID, true) { 652 | for _, blockedIRI := range blocked { 653 | if blockedIRI.Contains(act.ID, false) { 654 | o.Logger.WithContext(lw.Ctx{"actor": act.ID}).Warnf("Blocked") 655 | o.Error(errors.Gonef("nothing to see here, please move along")).ServeHTTP(w, r) 656 | return 657 | } 658 | } 659 | } 660 | } 661 | next.ServeHTTP(w, r) 662 | }) 663 | } 664 | 665 | func (o *oni) ActivityPubItem(w http.ResponseWriter, r *http.Request) { 666 | iri := irif(r) 667 | colFilters := make(filters.Checks, 0) 668 | 669 | if vocab.ValidCollectionIRI(iri) { 670 | if r.Method == http.MethodPost { 671 | o.ProcessActivity().ServeHTTP(w, r) 672 | return 673 | } 674 | _, whichCollection := vocab.Split(iri) 675 | 676 | colFilters = filters.FromValues(r.URL.Query()) 677 | if vocab.ValidActivityCollection(whichCollection) { 678 | accepts := getRequestAcceptedContentType(r) 679 | 680 | if accepts(textHTML) && (vocab.CollectionPaths{vocab.Outbox, vocab.Inbox}).Contains(whichCollection) { 681 | obFilters := make(filters.Checks, 0) 682 | obFilters = append(obFilters, filters.Not(filters.NilID)) 683 | if filtersCreateUpdate(colFilters) && !iriHasObjectTypeFilter(iri) { 684 | obFilters = append(obFilters, filters.HasType(validObjectTypes...)) 685 | } 686 | colFilters = append(colFilters, filters.HasType(vocab.CreateType)) 687 | if len(obFilters) > 0 { 688 | colFilters = append(colFilters, filters.Object(obFilters...)) 689 | } 690 | colFilters = append(colFilters, filters.Actor(filters.Not(filters.NilID))) 691 | } 692 | } 693 | 694 | if u, err := iri.URL(); err == nil { 695 | if after := u.Query().Get("after"); after != "" { 696 | colFilters = append(colFilters, filters.After(filters.SameID(vocab.IRI(after)))) 697 | } 698 | if after := u.Query().Get("before"); after != "" { 699 | colFilters = append(colFilters, filters.Before(filters.SameID(vocab.IRI(after)))) 700 | } 701 | } 702 | colFilters = append(colFilters, filters.WithMaxCount(MaxItems)) 703 | } 704 | if authActor, _ := o.loadAuthorizedActor(r, auth.AnonymousActor); authActor.ID != "" { 705 | colFilters = append(colFilters, filters.Authorized(authActor.ID)) 706 | } 707 | 708 | it, err := loadItemFromStorage(o.Storage, iri, colFilters...) 709 | if err != nil { 710 | if errors.IsNotFound(err) && len(o.a) == 1 { 711 | if a := o.a[0]; !a.ID.Equals(iri, true) { 712 | if _, cerr := checkIRIResolvesLocally(a.ID); cerr == nil { 713 | err = errors.NewTemporaryRedirect(err, a.ID.String()) 714 | } 715 | } 716 | } 717 | o.Error(err).ServeHTTP(w, r) 718 | return 719 | } 720 | it = vocab.CleanRecipients(it) 721 | _ = vocab.OnObject(it, func(o *vocab.Object) error { 722 | updatedAt := o.Published 723 | if !o.Updated.IsZero() { 724 | updatedAt = o.Updated 725 | } 726 | if !updatedAt.IsZero() { 727 | w.Header().Set("Last-Modified", updatedAt.Format(time.RFC1123)) 728 | } 729 | w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(24*time.Hour.Seconds()))) 730 | return nil 731 | }) 732 | accepts := getItemAcceptedContentType(it, r) 733 | switch { 734 | case accepts(jsonLD, activityJson, applicationJson): 735 | o.ServeActivityPubItem(it).ServeHTTP(w, r) 736 | case accepts(imageAny), accepts(audioAny), accepts(videoAny), accepts(pdfDocument): 737 | o.ServeBinData(it).ServeHTTP(w, r) 738 | case accepts(textHTML): 739 | fallthrough 740 | default: 741 | o.ServeHTML(it).ServeHTTP(w, r) 742 | } 743 | } 744 | 745 | func (o *oni) oniActor(r *http.Request) vocab.Actor { 746 | reqIRI := irif(r) 747 | for _, a := range o.a { 748 | if reqIRI.Contains(a.ID, false) { 749 | return a 750 | } 751 | } 752 | return auth.AnonymousActor 753 | } 754 | 755 | func titleFromItem(m vocab.Item, r *http.Request) func() template.HTML { 756 | title := bluemonday.StripTagsPolicy().Sanitize(vocab.NameOf(m)) 757 | if vocab.ActorTypes.Contains(m.GetType()) { 758 | details := "page" 759 | name := bluemonday.StripTagsPolicy().Sanitize(vocab.PreferredNameOf(m)) 760 | switch r.URL.Path { 761 | case "/": 762 | details = "profile page" 763 | title = fmt.Sprintf("%s :: fediverse %s", name, details) 764 | case "/inbox", "/outbox", "/followers", "/following": 765 | details = strings.TrimPrefix(r.URL.Path, "/") 766 | title = fmt.Sprintf("%s :: fediverse %s", name, details) 767 | } 768 | } 769 | 770 | return func() template.HTML { 771 | return template.HTML(title) 772 | } 773 | } 774 | 775 | func col(r *http.Request) vocab.CollectionPath { 776 | if r.URL == nil || len(r.URL.Path) == 0 { 777 | return vocab.Unknown 778 | } 779 | col := vocab.Unknown 780 | pathElements := strings.Split(r.URL.Path[1:], "/") // Skip first / 781 | for i := len(pathElements) - 1; i >= 0; i-- { 782 | col = vocab.CollectionPath(pathElements[i]) 783 | if vocab.ValidObjectCollection(col) || vocab.ValidActivityCollection(col) { 784 | return col 785 | } 786 | } 787 | 788 | return col 789 | } 790 | 791 | const MaxItems = 20 792 | 793 | func acceptFollows(o oni, f vocab.Follow, p processing.P) error { 794 | var accepter vocab.Actor 795 | for _, act := range o.a { 796 | if toBeFollowed := f.Object.GetID(); act.ID.Equals(toBeFollowed, true) { 797 | accepter = act 798 | break 799 | } 800 | } 801 | 802 | follower := f.Actor.GetID() 803 | if vocab.IsNil(accepter) { 804 | o.Logger.Warnf("Follow object does not match any root actor on this ONI instance") 805 | return errors.NotFoundf("Follow object Actor not found") 806 | } 807 | 808 | if blocks := o.loadBlockedActors(accepter); len(blocks) > 0 { 809 | // NOTE(marius): this should not happen as the StopBlock middleware has kicked in before 810 | if blocks.Contains(f.Actor) { 811 | o.Logger.WithContext(lw.Ctx{"blocked": follower}).Warnf("Follow actor is blocked") 812 | return errors.NotFoundf("Follow object Actor not found") 813 | } 814 | } 815 | 816 | accept := new(vocab.Accept) 817 | accept.Type = vocab.AcceptType 818 | _ = accept.To.Append(follower) 819 | accept.InReplyTo = f.GetID() 820 | accept.Object = f.GetID() 821 | 822 | l := lw.Ctx{"from": follower.GetLink(), "to": accepter.GetLink()} 823 | 824 | f.AttributedTo = accepter.GetLink() 825 | accept.Actor = accepter 826 | oniOutbox := vocab.Outbox.IRI(accepter) 827 | _, err := p.ProcessClientActivity(accept, accepter, oniOutbox) 828 | if err != nil { 829 | o.Logger.WithContext(l).Errorf("Failed processing %T[%s]: %s: %+s", accept, accept.Type, accept.ID, err) 830 | return err 831 | } 832 | o.Logger.WithContext(l).Infof("Accepted Follow: %s", f.ID) 833 | return nil 834 | } 835 | 836 | func IRIsContain(iris vocab.IRIs) func(i vocab.IRI) bool { 837 | return func(i vocab.IRI) bool { 838 | return iris.Contains(i) 839 | } 840 | } 841 | 842 | func Client(actor vocab.Actor, st processing.KeyLoader, l lw.Logger) *client.C { 843 | var tr http.RoundTripper = &http.Transport{} 844 | cachePath, err := os.UserCacheDir() 845 | if err != nil { 846 | cachePath = os.TempDir() 847 | } 848 | 849 | lctx := lw.Ctx{"log": "client"} 850 | if !vocab.PublicNS.Equals(actor.ID, true) { 851 | if prv, _ := st.LoadKey(actor.ID); prv != nil { 852 | tr = s2s.New(s2s.WithTransport(tr), s2s.WithActor(&actor, prv), s2s.WithLogger(l.WithContext(lw.Ctx{"log": "HTTP-Sig"}))) 853 | lctx["transport"] = "HTTP-Sig" 854 | lctx["actor"] = actor.GetLink() 855 | } 856 | } 857 | 858 | ua := fmt.Sprintf("%s/%s (+%s)", ProjectURL, Version, actor.GetLink()) 859 | baseClient := &http.Client{ 860 | Transport: client.UserAgentTransport(ua, cache.Private(tr, cache.FS(filepath.Join(cachePath, "oni")))), 861 | } 862 | 863 | return client.New( 864 | client.WithLogger(l.WithContext(lctx)), 865 | client.WithHTTPClient(baseClient), 866 | client.SkipTLSValidation(IsDev), 867 | ) 868 | } 869 | 870 | const ( 871 | jitterDelay = 50 * time.Millisecond 872 | 873 | baseWaitTime = time.Second 874 | multiplier = 1.4 875 | 876 | retries = 5 877 | ) 878 | 879 | func runWithRetry(fn ssm.Fn) ssm.Fn { 880 | return ssm.After(300*time.Millisecond, ssm.Retry(retries, ssm.BackOff(ssm.Jitter(jitterDelay, ssm.Linear(baseWaitTime, multiplier)), fn))) 881 | } 882 | 883 | // ProcessActivity handles POST requests to an ActivityPub actor's inbox/outbox, based on the CollectionType 884 | func (o *oni) ProcessActivity() processing.ActivityHandlerFn { 885 | baseIRIs := make(vocab.IRIs, 0) 886 | for _, act := range o.a { 887 | _ = baseIRIs.Append(act.GetID()) 888 | } 889 | 890 | return func(receivedIn vocab.IRI, r *http.Request) (vocab.Item, int, error) { 891 | var it vocab.Item 892 | lctx := lw.Ctx{} 893 | 894 | actor := o.oniActor(r) 895 | lctx["oni"] = actor.GetLink() 896 | 897 | author := auth.AnonymousActor 898 | if ok, err := o.ValidateRequest(r); !ok { 899 | lctx["err"] = err.Error() 900 | o.Logger.WithContext(lctx).Errorf("Failed request validation") 901 | return it, errors.HttpStatus(err), err 902 | } else { 903 | if stored, ok := r.Context().Value(authorizedActorCtxKey).(vocab.Actor); ok { 904 | author = stored 905 | } 906 | } 907 | 908 | c := Client(actor, o.Storage, o.Logger.WithContext(lctx, lw.Ctx{"log": "client"})) 909 | processor := processing.New( 910 | processing.Async, 911 | processing.WithLogger(o.Logger.WithContext(lctx, lw.Ctx{"log": "processing"})), 912 | processing.WithIRI(baseIRIs...), processing.WithClient(c), processing.WithStorage(o.Storage), 913 | processing.WithIDGenerator(GenerateID), processing.WithLocalIRIChecker(IRIsContain(baseIRIs)), 914 | ) 915 | 916 | body, err := io.ReadAll(r.Body) 917 | if err != nil || len(body) == 0 { 918 | lctx["err"] = err.Error() 919 | o.Logger.WithContext(lctx).Errorf("Failed loading body") 920 | return it, http.StatusInternalServerError, errors.NewNotValid(err, "unable to read request body") 921 | } 922 | 923 | defer logRequest(o, r.Header, body) 924 | 925 | if it, err = vocab.UnmarshalJSON(body); err != nil { 926 | lctx["err"] = err.Error() 927 | o.Logger.WithContext(lctx).Errorf("Failed unmarshalling jsonld body") 928 | return it, http.StatusInternalServerError, errors.NewNotValid(err, "unable to unmarshal JSON request") 929 | } 930 | if vocab.IsNil(it) { 931 | return it, http.StatusInternalServerError, errors.BadRequestf("unable to unmarshal JSON request") 932 | } 933 | 934 | if err != nil { 935 | lctx["err"] = err.Error() 936 | o.Logger.WithContext(lctx).Errorf("Failed initializing the Activity processor") 937 | return it, http.StatusInternalServerError, errors.NewNotValid(err, "unable to initialize processor") 938 | } 939 | if it, err = processor.ProcessActivity(it, author, receivedIn); err != nil { 940 | lctx["err"] = err.Error() 941 | o.Logger.WithContext(lctx).Errorf("Failed processing activity") 942 | err = errors.Annotatef(err, "Can't save %q activity to %s", it.GetType(), receivedIn) 943 | return it, errors.HttpStatus(err), err 944 | } 945 | 946 | if it.GetType() == vocab.FollowType { 947 | defer func() { 948 | go ssm.Run(context.Background(), runWithRetry(func(ctx context.Context) ssm.Fn { 949 | l := lw.Ctx{} 950 | err := vocab.OnActivity(it, func(a *vocab.Activity) error { 951 | l["from"] = a.Actor.GetLink() 952 | l["to"] = a.Object.GetLink() 953 | return acceptFollows(*o, *a, processor) 954 | }) 955 | if err != nil { 956 | l["err"] = err.Error() 957 | o.Logger.WithContext(lctx, l).Errorf("Unable to automatically accept follow") 958 | } 959 | return ssm.End 960 | })) 961 | }() 962 | } 963 | if it.GetType() == vocab.UpdateType { 964 | // NOTE(marius): if we updated one of the main actors, we replace it in the array 965 | _ = vocab.OnActivity(it, func(upd *vocab.Activity) error { 966 | ob := upd.Object 967 | for i, a := range o.a { 968 | if !a.ID.Equals(ob.GetID(), true) { 969 | continue 970 | } 971 | _ = vocab.OnActor(ob, func(actor *vocab.Actor) error { 972 | o.a[i] = *actor 973 | return nil 974 | }) 975 | } 976 | return nil 977 | }) 978 | } 979 | 980 | status := http.StatusCreated 981 | if it.GetType() == vocab.DeleteType { 982 | status = http.StatusGone 983 | } 984 | 985 | return it, status, nil 986 | } 987 | } 988 | 989 | var InMaintenanceMode bool = false 990 | 991 | func (o *oni) OutOfOrderMw(next http.Handler) http.Handler { 992 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 993 | if InMaintenanceMode { 994 | o.Error(errors.ServiceUnavailablef("temporarily out of order")).ServeHTTP(w, r) 995 | return 996 | } 997 | next.ServeHTTP(w, r) 998 | }) 999 | } 1000 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package oni 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "encoding/base64" 8 | "encoding/pem" 9 | "fmt" 10 | "net/http" 11 | "os" 12 | "time" 13 | 14 | vocab "github.com/go-ap/activitypub" 15 | ) 16 | 17 | func DefaultValue(name string) vocab.NaturalLanguageValues { 18 | return vocab.NaturalLanguageValues{vocab.LangRefValueNew(vocab.NilLangRef, name)} 19 | } 20 | 21 | func SetPreferredUsername(i vocab.Item, name vocab.NaturalLanguageValues) error { 22 | return vocab.OnActor(i, func(actor *vocab.Actor) error { 23 | actor.PreferredUsername = name 24 | return nil 25 | }) 26 | } 27 | 28 | func IRIPath(iri vocab.IRI) (string, bool) { 29 | u, err := iri.URL() 30 | if err != nil { 31 | return "/", false 32 | } 33 | if u.Path == "" { 34 | u.Path = "/" 35 | } 36 | return u.Path, true 37 | } 38 | 39 | func IRIHost(iri vocab.IRI) (string, bool) { 40 | u, err := iri.URL() 41 | if err != nil { 42 | return "", false 43 | } 44 | return u.Host, true 45 | } 46 | 47 | func CollectionExists(ob vocab.Item, col vocab.CollectionPath) bool { 48 | has := false 49 | switch col { 50 | case vocab.Outbox: 51 | _ = vocab.OnActor(ob, func(actor *vocab.Actor) error { 52 | has = actor.Outbox != nil 53 | return nil 54 | }) 55 | case vocab.Inbox: 56 | _ = vocab.OnActor(ob, func(actor *vocab.Actor) error { 57 | has = actor.Inbox != nil 58 | return nil 59 | }) 60 | case vocab.Liked: 61 | _ = vocab.OnActor(ob, func(actor *vocab.Actor) error { 62 | has = actor.Liked != nil 63 | return nil 64 | }) 65 | case vocab.Following: 66 | _ = vocab.OnActor(ob, func(actor *vocab.Actor) error { 67 | has = actor.Following != nil 68 | return nil 69 | }) 70 | case vocab.Followers: 71 | _ = vocab.OnActor(ob, func(actor *vocab.Actor) error { 72 | has = actor.Followers != nil 73 | return nil 74 | }) 75 | case vocab.Likes: 76 | _ = vocab.OnObject(ob, func(ob *vocab.Object) error { 77 | has = ob.Likes != nil 78 | return nil 79 | }) 80 | case vocab.Shares: 81 | _ = vocab.OnObject(ob, func(ob *vocab.Object) error { 82 | has = ob.Shares != nil 83 | return nil 84 | }) 85 | case vocab.Replies: 86 | _ = vocab.OnObject(ob, func(ob *vocab.Object) error { 87 | has = ob.Replies != nil 88 | return nil 89 | }) 90 | } 91 | return has 92 | } 93 | 94 | func pemEncodePublicKey(prvKey *rsa.PrivateKey) string { 95 | if prvKey == nil { 96 | return "" 97 | } 98 | pubKey := prvKey.PublicKey 99 | pubEnc, err := x509.MarshalPKIXPublicKey(&pubKey) 100 | if err != nil { 101 | panic(err) 102 | } 103 | p := pem.Block{ 104 | Type: "PUBLIC KEY", 105 | Bytes: pubEnc, 106 | } 107 | 108 | return string(pem.EncodeToMemory(&p)) 109 | } 110 | 111 | func GenerateID(it vocab.Item, col vocab.Item, by vocab.Item) (vocab.ID, error) { 112 | if it.GetID() != "" { 113 | return it.GetID(), nil 114 | } 115 | 116 | typ := it.GetType() 117 | 118 | uuid := fmt.Sprintf("%d", time.Now().UTC().UnixMilli()) 119 | 120 | if vocab.ActivityTypes.Contains(typ) || vocab.IntransitiveActivityTypes.Contains(typ) { 121 | err := vocab.OnActivity(it, func(a *vocab.Activity) error { 122 | return vocab.OnActor(a.Actor, func(author *vocab.Actor) error { 123 | a.ID = vocab.Outbox.IRI(author).AddPath(uuid) 124 | return nil 125 | }) 126 | }) 127 | return it.GetID(), err 128 | } 129 | 130 | var id vocab.ID 131 | if by != nil { 132 | id = by.GetLink().AddPath("object") 133 | if it.IsLink() { 134 | return id, vocab.OnLink(it, func(l *vocab.Link) error { 135 | l.ID = id 136 | return nil 137 | }) 138 | } 139 | return id, vocab.OnObject(it, func(o *vocab.Object) error { 140 | o.ID = id 141 | return nil 142 | }) 143 | } 144 | 145 | return id, nil 146 | } 147 | 148 | func getBinData(nlVal vocab.NaturalLanguageValues, mt vocab.MimeType) (string, []byte, error) { 149 | val := nlVal.First().Value 150 | 151 | contentType := "application/octet-stream" 152 | if mt != "" { 153 | contentType = string(mt) 154 | } 155 | colPos := bytes.Index(val, []byte{':'}) 156 | if colPos < 0 { 157 | colPos = 0 158 | } 159 | 160 | semicolPos := bytes.Index(val, []byte{';'}) 161 | if semicolPos > 0 { 162 | contentType = string(val[colPos+1 : semicolPos]) 163 | } 164 | comPos := bytes.Index(val, []byte{','}) 165 | var raw []byte 166 | if semicolPos > 0 && comPos > 0 { 167 | decType := val[semicolPos+1 : comPos] 168 | 169 | switch string(decType) { 170 | case "base64": 171 | data := val[comPos+1:] 172 | 173 | dec := base64.RawStdEncoding 174 | raw = make([]byte, dec.DecodedLen(len(data))) 175 | cnt, err := dec.Decode(raw, data) 176 | if err != nil { 177 | return contentType, raw, err 178 | } 179 | if cnt != len(data) { 180 | // something wrong 181 | } 182 | } 183 | } else { 184 | raw = val 185 | } 186 | 187 | return contentType, raw, nil 188 | } 189 | 190 | func isData(nlVal vocab.NaturalLanguageValues) bool { 191 | return len(nlVal) > 0 && bytes.Equal(nlVal.First().Value[:4], []byte("data")) 192 | } 193 | 194 | func irif(r *http.Request) vocab.IRI { 195 | return vocab.IRI(fmt.Sprintf("https://%s%s", r.Host, r.RequestURI)) 196 | } 197 | 198 | func logRequest(o *oni, h http.Header, body []byte) { 199 | fn := fmt.Sprintf("%s/%s.req", o.StoragePath, time.Now().UTC().Format(time.RFC3339)) 200 | all := bytes.Buffer{} 201 | _ = h.Write(&all) 202 | all.Write([]byte{'\n', '\n'}) 203 | all.Write(body) 204 | _ = os.WriteFile(fn, all.Bytes(), 0660) 205 | } 206 | -------------------------------------------------------------------------------- /images/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.dev 3 | *.crt 4 | *.key 5 | *.pem 6 | -------------------------------------------------------------------------------- /images/Makefile: -------------------------------------------------------------------------------- 1 | SHELL := sh 2 | .ONESHELL: 3 | .SHELLFLAGS := -eu -o pipefail -c 4 | .DELETE_ON_ERROR: 5 | MAKEFLAGS += --warn-undefined-variables 6 | MAKEFLAGS += --no-builtin-rules 7 | 8 | ENV ?= prod 9 | APP_HOSTNAME ?= oni 10 | PORT ?= 4000 11 | TAG ?= $(ENV) 12 | VERSION ?= HEAD 13 | 14 | BUILD_CMD=buildah bud 15 | RUN_CMD=podman run 16 | TAG_CMD=podman tag 17 | PUSH_CMD=podman push 18 | 19 | .PHONY: clean build builder run push cert 20 | 21 | $(APP_HOSTNAME).pem: 22 | ./gen-certs.sh $(APP_HOSTNAME) 23 | 24 | cert: $(APP_HOSTNAME).pem 25 | 26 | clean: 27 | @-$(RM) $(APP_HOSTNAME).{key,crt,pem} 28 | 29 | builder: 30 | ./build.sh .. oni/builder 31 | 32 | build: 33 | ENV=$(ENV) VERSION=$(VERSION) PORT=$(PORT) HOSTNAME=$(APP_HOSTNAME) ./image.sh $(APP_HOSTNAME)/app:$(TAG) 34 | 35 | push: build 36 | $(TAG_CMD) $(APP_HOSTNAME)/app:$(TAG) quay.io/go-ap/oni:$(TAG) 37 | $(PUSH_CMD) quay.io/go-ap/oni:$(TAG) 38 | ifeq ($(TAG),dev) 39 | $(TAG_CMD) $(APP_HOSTNAME)/app:$(TAG) quay.io/go-ap/oni:latest || true 40 | $(PUSH_CMD) quay.io/go-ap/oni:latest || true 41 | endif 42 | ifneq ($(VERSION),) 43 | $(TAG_CMD) $(APP_HOSTNAME)/app:$(TAG) quay.io/go-ap/oni:$(VERSION)-$(TAG) || true 44 | $(PUSH_CMD) quay.io/go-ap/oni:$(VERSION)-$(TAG) || true 45 | endif 46 | -------------------------------------------------------------------------------- /images/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | #set -x 4 | 5 | _workdir=${1:-../} 6 | _image_name=${2:-oni/builder} 7 | _go_version=${GO_VERSION:-1.24} 8 | 9 | _context=$(realpath "${_workdir}") 10 | 11 | _builder=$(buildah from docker.io/library/golang:${_go_version}-alpine) 12 | 13 | buildah run "${_builder}" /sbin/apk update 14 | buildah run "${_builder}" /sbin/apk add yarn make bash openssl upx 15 | 16 | buildah config --env GO111MODULE=on "${_builder}" 17 | buildah config --env GOWORK=off "${_builder}" 18 | buildah config --env YARN=yarnpkg "${_builder}" 19 | 20 | buildah copy --ignorefile "${_context}/.containerignore" --contextdir "${_context}" "${_builder}" "${_context}" /go/src/app 21 | buildah config --workingdir /go/src/app "${_builder}" 22 | 23 | buildah run "${_builder}" make go.sum yarn.lock 24 | buildah run "${_builder}" go mod vendor 25 | 26 | buildah commit "${_builder}" "${_image_name}" 27 | -------------------------------------------------------------------------------- /images/gen-certs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | APP_HOSTNAME="${1}" 3 | 4 | openssl req \ 5 | -subj "/C=AQ/ST=Omond/L=Omond/O=${APP_HOSTNAME}/OU=none/CN=${APP_HOSTNAME}" \ 6 | -newkey rsa:2048 -sha256 \ 7 | -keyout "${APP_HOSTNAME}.key" \ 8 | -nodes -x509 -days 365 \ 9 | -out "${APP_HOSTNAME}.crt" && \ 10 | cat "${APP_HOSTNAME}.key" "${APP_HOSTNAME}.crt" > "${APP_HOSTNAME}.pem" 11 | -------------------------------------------------------------------------------- /images/image.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | #set -x 4 | 5 | _environment=${ENV:-dev} 6 | _hostname=${APP_HOSTNAME:-oni} 7 | _listen_port=${PORT:-5668} 8 | _storage=${STORAGE:-all} 9 | _version=${VERSION:-HEAD} 10 | 11 | _image_name=${1:-oni:"${_environment}-${_storage}"} 12 | _build_name=${2:-localhost/oni/builder} 13 | 14 | _builder=$(buildah from "${_build_name}":latest) 15 | if [ -z "${_builder}" ]; then 16 | echo "Unable to find builder image: ${_build_name}" 17 | exit 1 18 | fi 19 | 20 | echo "Building image ${_image_name} for host=${_hostname} env:${_environment} storage:${_storage} version:${_version} port:${_listen_port}" 21 | 22 | buildah run "${_builder}" make ENV="${_environment}" STORAGE="${_storage}" VERSION="${_version}" all 23 | buildah run "${_builder}" ./images/gen-certs.sh "${_hostname}" 24 | 25 | _image=$(buildah from gcr.io/distroless/static:latest) 26 | 27 | buildah config --env ENV="${_environment}" "${_image}" 28 | buildah config --env APP_HOSTNAME="${_hostname}" "${_image}" 29 | buildah config --env LISTEN=:"${_listen_port}" "${_image}" 30 | buildah config --env KEY_PATH="/etc/ssl/certs/${_hostname}.key" "${_image}" 31 | buildah config --env CERT_PATH="/etc/ssl/certs/${_hostname}.crt" "${_image}" 32 | buildah config --env HTTPS=true "${_image}" 33 | buildah config --env STORAGE="${_storage}" "${_image}" 34 | 35 | buildah config --port "${_listen_port}" "${_image}" 36 | 37 | buildah config --volume /storage "${_image}" 38 | 39 | buildah copy --from "${_builder}" "${_image}" /go/src/app/bin/* /bin/ 40 | buildah copy --from "${_builder}" "${_image}" "/go/src/app/${_hostname}.key" /etc/ssl/certs/ 41 | buildah copy --from "${_builder}" "${_image}" "/go/src/app/${_hostname}.crt" /etc/ssl/certs/ 42 | buildah copy --from "${_builder}" "${_image}" "/go/src/app/${_hostname}.pem" /etc/ssl/certs/ 43 | 44 | buildah config --workingdir / "${_image}" 45 | buildah config --entrypoint "$(printf '["/bin/oni", "-listen", ":%s", "-path", "/storage"]' "${_listen_port}")" "${_image}" 46 | 47 | # commit 48 | buildah commit "${_image}" "${_image_name}" 49 | -------------------------------------------------------------------------------- /internal/esbuild/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/evanw/esbuild/pkg/api" 9 | ) 10 | 11 | func main() { 12 | env := os.Getenv("ENV") 13 | isProd := strings.HasPrefix(strings.ToLower(env), "prod") || strings.HasPrefix(strings.ToLower(env), "qa") 14 | 15 | buildJS(isProd) 16 | buildCSS(isProd) 17 | copyOthers() 18 | } 19 | 20 | func buildJS(prod bool) { 21 | opt := api.BuildOptions{ 22 | LogLevel: api.LogLevelDebug, 23 | EntryPoints: []string{"src/js/main.jsx"}, 24 | Bundle: true, 25 | Platform: api.PlatformBrowser, 26 | Write: true, 27 | Outfile: "static/main.js", 28 | } 29 | if prod { 30 | opt.LogLevel = api.LogLevelInfo 31 | opt.MinifyWhitespace = true 32 | opt.MinifyIdentifiers = true 33 | opt.MinifySyntax = true 34 | opt.Sourcemap = api.SourceMapLinked 35 | } 36 | // JS 37 | result := api.Build(opt) 38 | 39 | if len(result.Errors) > 0 { 40 | _, _ = fmt.Fprintf(os.Stderr, "%v", result.Errors) 41 | } 42 | } 43 | 44 | func buildCSS(prod bool) { 45 | opt := api.BuildOptions{ 46 | LogLevel: api.LogLevelDebug, 47 | EntryPoints: []string{"src/css/main.css"}, 48 | Bundle: true, 49 | Platform: api.PlatformBrowser, 50 | Write: true, 51 | Outfile: "static/main.css", 52 | } 53 | if prod { 54 | opt.LogLevel = api.LogLevelInfo 55 | opt.MinifyWhitespace = true 56 | opt.MinifyIdentifiers = true 57 | opt.MinifySyntax = true 58 | opt.Sourcemap = api.SourceMapLinked 59 | } 60 | // CSS 61 | result := api.Build(opt) 62 | 63 | if len(result.Errors) > 0 { 64 | _, _ = fmt.Fprintf(os.Stderr, "%v", result.Errors) 65 | } 66 | } 67 | 68 | var others = []string{ 69 | "src/icons.svg", 70 | "src/robots.txt", 71 | } 72 | 73 | func copyOthers() { 74 | for _, other := range others { 75 | ff, err := os.ReadFile(other) 76 | if err != nil { 77 | _, _ = fmt.Fprintf(os.Stderr, "%v", err) 78 | return 79 | } 80 | 81 | err = os.WriteFile(strings.Replace(other, "src", "static", 1), ff, 0600) 82 | if err != nil { 83 | _, _ = fmt.Fprintf(os.Stderr, "%v", err) 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package oni 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | "time" 8 | 9 | "git.sr.ht/~mariusor/lw" 10 | "github.com/go-chi/chi/v5/middleware" 11 | ) 12 | 13 | func Log(l lw.Logger) func(next http.Handler) http.Handler { 14 | return func(next http.Handler) http.Handler { 15 | fn := func(w http.ResponseWriter, r *http.Request) { 16 | entry := req(l, r) 17 | ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) 18 | 19 | buf := bytes.NewBuffer(make([]byte, 0, 512)) 20 | ww.Tee(buf) 21 | 22 | t1 := time.Now() 23 | defer func() { 24 | var respBody []byte 25 | if ww.Status() >= 400 { 26 | respBody, _ = io.ReadAll(buf) 27 | } 28 | entry.Write(ww.Status(), ww.BytesWritten(), ww.Header(), time.Since(t1), respBody) 29 | }() 30 | 31 | next.ServeHTTP(ww, middleware.WithLogEntry(r, entry)) 32 | } 33 | return http.HandlerFunc(fn) 34 | } 35 | } 36 | 37 | type reqLogger struct { 38 | lw.Logger 39 | } 40 | 41 | func (r reqLogger) Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) { 42 | ctx := lw.Ctx{ 43 | "st": status, 44 | "size": bytes, 45 | } 46 | if elapsed > 0 { 47 | ctx["elapsed"] = elapsed 48 | } 49 | 50 | var logFn func(string, ...any) 51 | 52 | switch { 53 | case status <= 0: 54 | logFn = r.Logger.WithContext(ctx).Warnf 55 | case status < 400: // for codes in 100s, 200s, 300s 56 | logFn = r.Logger.WithContext(ctx).Infof 57 | case status >= 400 && status < 500: 58 | logFn = r.Logger.WithContext(ctx).Warnf 59 | case status >= 500: 60 | logFn = r.Logger.WithContext(ctx).Errorf 61 | default: 62 | logFn = r.Logger.WithContext(ctx).Infof 63 | } 64 | logFn(http.StatusText(status)) 65 | } 66 | 67 | func (r reqLogger) Panic(v interface{}, stack []byte) { 68 | r.Logger.WithContext(lw.Ctx{"panic": v}).Tracef("") 69 | } 70 | 71 | func req(l lw.Logger, r *http.Request) reqLogger { 72 | ctx := lw.Ctx{ 73 | "method": r.Method, 74 | "iri": irif(r), 75 | } 76 | 77 | if acc := r.Header.Get("Accept"); acc != "" { 78 | ctx["accept"] = acc 79 | } 80 | if ua := r.Header.Get("User-Agent"); ua != "" { 81 | ctx["ua"] = ua 82 | } 83 | 84 | return reqLogger{l.WithContext(ctx)} 85 | } 86 | -------------------------------------------------------------------------------- /oauth.go: -------------------------------------------------------------------------------- 1 | package oni 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "html/template" 9 | "io" 10 | "math/rand" 11 | "net/http" 12 | "net/url" 13 | 14 | "git.sr.ht/~mariusor/lw" 15 | ct "github.com/elnormous/contenttype" 16 | vocab "github.com/go-ap/activitypub" 17 | "github.com/go-ap/auth" 18 | "github.com/go-ap/errors" 19 | "github.com/go-ap/filters" 20 | "github.com/go-ap/processing" 21 | "github.com/openshift/osin" 22 | "golang.org/x/oauth2" 23 | ) 24 | 25 | type ClientSaver interface { 26 | // UpdateClient updates the client (identified by it's id) and replaces the values with the values of client. 27 | UpdateClient(c osin.Client) error 28 | // CreateClient stores the client in the database and returns an error, if something went wrong. 29 | CreateClient(c osin.Client) error 30 | // RemoveClient removes a client (identified by id) from the database. Returns an error if something went wrong. 31 | RemoveClient(id string) error 32 | } 33 | 34 | type ClientLister interface { 35 | GetClient(id string) (osin.Client, error) 36 | } 37 | 38 | type FullStorage interface { 39 | ClientSaver 40 | ClientLister 41 | PasswordChanger 42 | osin.Storage 43 | processing.Store 44 | processing.KeyLoader 45 | MetadataTyper 46 | } 47 | 48 | type PasswordChanger interface { 49 | PasswordSet(vocab.Item, []byte) error 50 | PasswordCheck(vocab.Item, []byte) error 51 | } 52 | 53 | type authModel struct { 54 | AuthorizeURL string `json:"authorizeURL"` 55 | State string `json:"state"` 56 | } 57 | 58 | func AuthorizeURL(actor vocab.Actor, state string) string { 59 | u, _ := actor.ID.URL() 60 | config := oauth2.Config{ClientID: u.Host, RedirectURL: actor.ID.String()} 61 | if !vocab.IsNil(actor) && actor.Endpoints != nil { 62 | if actor.Endpoints.OauthTokenEndpoint != nil { 63 | config.Endpoint.TokenURL = actor.Endpoints.OauthTokenEndpoint.GetLink().String() 64 | } 65 | if actor.Endpoints.OauthAuthorizationEndpoint != nil { 66 | config.Endpoint.AuthURL = actor.Endpoints.OauthAuthorizationEndpoint.GetLink().String() 67 | } 68 | } 69 | return config.AuthCodeURL(state, oauth2.AccessTypeOnline) 70 | } 71 | 72 | func (o *oni) loadAccountFromPost(actor vocab.Actor, r *http.Request) error { 73 | pw := r.PostFormValue("_pw") 74 | 75 | o.Logger.WithContext(lw.Ctx{"pass": pw}).Infof("Received") 76 | 77 | return o.Storage.PasswordCheck(actor, []byte(pw)) 78 | } 79 | 80 | func actorIRIFromRequest(r *http.Request) vocab.IRI { 81 | rr := *r 82 | rr.RequestURI = "/" 83 | return irif(&rr) 84 | } 85 | 86 | func loadBaseActor(o *oni, r *http.Request) (vocab.Actor, error) { 87 | result, err := o.Storage.Load(actorIRIFromRequest(r)) 88 | if err != nil { 89 | return auth.AnonymousActor, err 90 | } 91 | actor := auth.AnonymousActor 92 | err = vocab.OnActor(result, func(act *vocab.Actor) error { 93 | actor = *act 94 | return nil 95 | }) 96 | return actor, err 97 | } 98 | 99 | func authServer(o *oni, oniActor vocab.Actor) (*auth.Server, error) { 100 | return auth.New( 101 | auth.WithIRI(oniActor.GetLink()), 102 | auth.WithStorage(o.Storage), 103 | auth.WithClient(Client(oniActor, o.Storage, o.Logger)), 104 | auth.WithLogger(o.Logger.WithContext(lw.Ctx{"log": "osin"})), 105 | ) 106 | } 107 | 108 | func (o *oni) Authorize(w http.ResponseWriter, r *http.Request) { 109 | a, err := loadBaseActor(o, r) 110 | if err != nil { 111 | o.Error(err).ServeHTTP(w, r) 112 | return 113 | } 114 | 115 | acceptableMediaTypes := []ct.MediaType{textHTML, applicationJson} 116 | acc, _, _ := ct.GetAcceptableMediaType(r, acceptableMediaTypes) 117 | if r.Method == http.MethodGet && !acc.EqualsMIME(textHTML) { 118 | state := base64.URLEncoding.EncodeToString(authKey()) 119 | m := authModel{ 120 | AuthorizeURL: AuthorizeURL(a, state), 121 | State: state, 122 | } 123 | 124 | _ = json.NewEncoder(w).Encode(m) 125 | return 126 | } 127 | 128 | s, err := authServer(o, a) 129 | if err != nil { 130 | o.Error(errors.Annotatef(err, "Unable to initialize OAuth2 server")) 131 | return 132 | } 133 | resp := s.NewResponse() 134 | 135 | if ar := s.HandleAuthorizeRequest(resp, r); ar != nil { 136 | if r.Method == http.MethodGet { 137 | // this is basically the login page, with client being set 138 | m := login{title: "Login"} 139 | m.backURL = backURL(r) 140 | 141 | clientIRI := vocab.IRI(fmt.Sprintf("https://%s", ar.Client.GetId())) 142 | it, err := o.Storage.Load(clientIRI) 143 | if err != nil { 144 | o.Logger.WithContext(lw.Ctx{"err": err, "iri": clientIRI}).Errorf("Invalid client") 145 | errors.HandleError(errors.Unauthorizedf("Invalid client")).ServeHTTP(w, r) 146 | return 147 | } 148 | if !vocab.IsNil(it) { 149 | m.client = it 150 | m.state = ar.State 151 | } else { 152 | resp.SetError(osin.E_INVALID_REQUEST, fmt.Sprintf("Invalid client: %+s", err)) 153 | o.redirectOrOutput(resp, w, r) 154 | return 155 | } 156 | 157 | o.renderTemplate(r, w, "login", m) 158 | return 159 | } else { 160 | if err := o.loadAccountFromPost(a, r); err != nil { 161 | o.Logger.WithContext(lw.Ctx{"err": err.Error()}).Errorf("wrong password") 162 | errors.HandleError(errors.Unauthorizedf("Wrong password")).ServeHTTP(w, r) 163 | return 164 | } 165 | ar.Authorized = true 166 | ar.UserData = a.ID 167 | s.FinishAuthorizeRequest(resp, r, ar) 168 | } 169 | } 170 | if !acc.Equal(textHTML) { 171 | resp.Type = osin.DATA 172 | } 173 | o.redirectOrOutput(resp, w, r) 174 | } 175 | 176 | var ( 177 | errUnauthorized = errors.Unauthorizedf("Invalid username or password") 178 | errNotFound = filters.ErrNotFound("actor not found") 179 | ) 180 | 181 | func (o *oni) Token(w http.ResponseWriter, r *http.Request) { 182 | a, err := loadBaseActor(o, r) 183 | if err != nil { 184 | o.Error(err).ServeHTTP(w, r) 185 | return 186 | } 187 | 188 | s, err := authServer(o, a) 189 | if err != nil { 190 | o.Logger.WithContext(lw.Ctx{"err": err.Error()}).Errorf("Unable to initialize OAuth2 server") 191 | o.Error(err).ServeHTTP(w, r) 192 | return 193 | } 194 | 195 | resp := s.NewResponse() 196 | 197 | actor := &auth.AnonymousActor 198 | if ar := s.HandleAccessRequest(resp, r); ar != nil { 199 | actorIRI := a.ID 200 | if iri, ok := ar.UserData.(string); ok { 201 | actorIRI = vocab.IRI(iri) 202 | } 203 | it, err := o.Storage.Load(actorIRI) 204 | if err != nil { 205 | o.Logger.Errorf("%s", errUnauthorized) 206 | errors.HandleError(errUnauthorized).ServeHTTP(w, r) 207 | return 208 | } 209 | 210 | if actor, err = vocab.ToActor(it); err != nil { 211 | o.Logger.Errorf("%s", errUnauthorized) 212 | errors.HandleError(errUnauthorized).ServeHTTP(w, r) 213 | return 214 | } 215 | 216 | ar.Authorized = !actor.GetID().Equals(auth.AnonymousActor.ID, true) 217 | ar.UserData = actor.GetLink() 218 | s.FinishAccessRequest(resp, r, ar) 219 | } 220 | 221 | acc, _, _ := ct.GetAcceptableMediaType(r, []ct.MediaType{textHTML, applicationJson}) 222 | if !acc.Equal(textHTML) { 223 | resp.Type = osin.DATA 224 | } 225 | o.redirectOrOutput(resp, w, r) 226 | } 227 | 228 | func annotatedRsError(status int, old error, msg string, args ...interface{}) error { 229 | var err error 230 | switch status { 231 | case http.StatusForbidden: 232 | err = errors.NewForbidden(old, msg, args...) 233 | case http.StatusUnauthorized: 234 | err = errors.NewUnauthorized(old, msg, args...) 235 | case http.StatusInternalServerError: 236 | fallthrough 237 | default: 238 | err = errors.Annotatef(old, msg, args...) 239 | } 240 | 241 | return err 242 | } 243 | 244 | func (o *oni) redirectOrOutput(rs *osin.Response, w http.ResponseWriter, r *http.Request) { 245 | if rs.IsError { 246 | err := annotatedRsError(rs.StatusCode, rs.InternalError, "Error processing OAuth2 request: %s", rs.StatusText) 247 | o.Error(err).ServeHTTP(w, r) 248 | return 249 | } 250 | // Add headers 251 | for i, k := range rs.Headers { 252 | for _, v := range k { 253 | w.Header().Add(i, v) 254 | } 255 | } 256 | 257 | if rs.Type == osin.REDIRECT { 258 | // Output redirect with parameters 259 | u, err := rs.GetRedirectUrl() 260 | if err != nil { 261 | err := annotatedRsError(http.StatusInternalServerError, err, "Error getting OAuth2 redirect URL") 262 | o.Error(err).ServeHTTP(w, r) 263 | return 264 | } 265 | 266 | http.Redirect(w, r, u, http.StatusFound) 267 | } else { 268 | // set content type if the response doesn't already have one associated with it 269 | if w.Header().Get("Content-Type") == "" { 270 | w.Header().Set("Content-Type", "application/json") 271 | } 272 | w.WriteHeader(rs.StatusCode) 273 | 274 | if err := json.NewEncoder(w).Encode(rs.Output); err != nil { 275 | o.Error(err).ServeHTTP(w, r) 276 | return 277 | } 278 | } 279 | } 280 | 281 | const DefaultOAuth2ClientPw = "NotSoSecretPassword" 282 | 283 | func (c *Control) CreateOAuth2ClientIfMissing(i vocab.IRI, pw string) error { 284 | u, _ := i.URL() 285 | 286 | cl, err := c.Storage.GetClient(u.Host) 287 | if err == nil { 288 | return nil 289 | } 290 | cl = &osin.DefaultClient{ 291 | Id: u.Host, 292 | Secret: pw, 293 | RedirectUri: u.String(), 294 | UserData: i, 295 | } 296 | return c.Storage.CreateClient(cl) 297 | } 298 | 299 | var authKey = func() []byte { 300 | v1 := rand.Int() 301 | v2 := rand.Int() 302 | b := [16]byte{ 303 | byte(0xff & v1), 304 | byte(0xff & v2), 305 | byte(0xff & (v1 >> 8)), 306 | byte(0xff & (v2 >> 8)), 307 | byte(0xff & (v1 >> 16)), 308 | byte(0xff & (v2 >> 16)), 309 | byte(0xff & (v1 >> 24)), 310 | byte(0xff & (v2 >> 24)), 311 | byte(0xff & (v1 >> 32)), 312 | byte(0xff & (v2 >> 32)), 313 | byte(0xff & (v1 >> 40)), 314 | byte(0xff & (v2 >> 40)), 315 | byte(0xff & (v1 >> 48)), 316 | byte(0xff & (v2 >> 48)), 317 | byte(0xff & (v1 >> 56)), 318 | byte(0xff & (v2 >> 56)), 319 | } 320 | return b[:] 321 | } 322 | 323 | func backURL(r *http.Request) string { 324 | if r.URL == nil || r.URL.Query() == nil { 325 | return "" 326 | } 327 | q := make(url.Values) 328 | q.Set("error", osin.E_UNAUTHORIZED_CLIENT) 329 | q.Set("error_description", "user denied authorization request") 330 | u, _ := url.QueryUnescape(r.URL.Query().Get("redirect_uri")) 331 | u = fmt.Sprintf("%s?%s", u, q.Encode()) 332 | return u 333 | } 334 | 335 | func (o *oni) renderTemplate(r *http.Request, w http.ResponseWriter, name string, m model) { 336 | wrt := bytes.Buffer{} 337 | 338 | actor := o.oniActor(r) 339 | renderOptions.Funcs = template.FuncMap{ 340 | "ONI": func() vocab.Actor { return actor }, 341 | "URLS": actorURLs(actor), 342 | "Title": m.Title, 343 | "CurrentURL": func() template.HTMLAttr { 344 | return template.HTMLAttr(fmt.Sprintf("https://%s%s", r.Host, r.RequestURI)) 345 | }, 346 | } 347 | 348 | err := ren.HTML(&wrt, http.StatusOK, name, m, renderOptions) 349 | if err == nil { 350 | _, _ = io.Copy(w, &wrt) 351 | return 352 | } 353 | o.Error(errors.Annotatef(err, "failed to render template"))(w, r) 354 | } 355 | 356 | type login struct { 357 | title string 358 | state string 359 | client vocab.Item 360 | backURL string 361 | } 362 | 363 | func (l login) Title() string { 364 | return l.title 365 | } 366 | 367 | func (l login) BackURL() template.HTMLAttr { 368 | return template.HTMLAttr(l.backURL) 369 | } 370 | 371 | func (l login) State() string { 372 | return l.state 373 | } 374 | 375 | func (l login) Client() vocab.Item { 376 | return l.client 377 | } 378 | 379 | type model interface { 380 | Title() string 381 | } 382 | -------------------------------------------------------------------------------- /oni.go: -------------------------------------------------------------------------------- 1 | package oni 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/x509" 8 | "encoding/pem" 9 | "fmt" 10 | "net" 11 | "net/http" 12 | "os" 13 | "path/filepath" 14 | "strings" 15 | "syscall" 16 | "time" 17 | 18 | "git.sr.ht/~mariusor/lw" 19 | w "git.sr.ht/~mariusor/wrapper" 20 | vocab "github.com/go-ap/activitypub" 21 | "github.com/go-ap/auth" 22 | "github.com/go-ap/errors" 23 | "github.com/go-ap/processing" 24 | storage "github.com/go-ap/storage-fs" 25 | ) 26 | 27 | var ( 28 | Version = "(devel)" 29 | ProjectURL = "https://git.sr.ht/~mariusor/oni" 30 | DefaultURL = "https://oni.local" 31 | ) 32 | 33 | type oni struct { 34 | Control 35 | 36 | Listen string 37 | StoragePath string 38 | TimeOut time.Duration 39 | PwHash []byte 40 | 41 | a []vocab.Actor 42 | m http.Handler 43 | } 44 | 45 | type optionFn func(o *oni) 46 | 47 | func (c *Control) UpdateActorKey(actor *vocab.Actor) (*vocab.Actor, error) { 48 | st := c.Storage 49 | l := c.Logger 50 | 51 | key, err := rsa.GenerateKey(rand.Reader, 2048) 52 | if err != nil { 53 | return actor, errors.Annotatef(err, "unable to save Private Key") 54 | } 55 | 56 | typ := actor.GetType() 57 | if !vocab.ActorTypes.Contains(typ) { 58 | return actor, errors.Newf("trying to generate keys for invalid ActivityPub object type: %s", typ) 59 | } 60 | 61 | iri := actor.ID 62 | 63 | m, err := st.LoadMetadata(iri) 64 | if err != nil && !errors.IsNotFound(err) { 65 | return actor, err 66 | } 67 | if m == nil { 68 | m = new(auth.Metadata) 69 | } 70 | if m.PrivateKey != nil { 71 | l.WithContext(lw.Ctx{"iri": iri}).Debugf("Actor already has a private key") 72 | } 73 | 74 | prvEnc, err := x509.MarshalPKCS8PrivateKey(key) 75 | if err != nil { 76 | l.WithContext(lw.Ctx{"key": key, "iri": iri}).Errorf("Unable to x509.MarshalPKCS8PrivateKey()") 77 | return actor, err 78 | } 79 | 80 | pub := key.Public() 81 | pubEnc, err := x509.MarshalPKIXPublicKey(pub) 82 | if err != nil { 83 | l.WithContext(lw.Ctx{"pubKey": pub, "iri": iri}).Errorf("Unable to x509.MarshalPKIXPublicKey()") 84 | return actor, err 85 | } 86 | pubEncoded := pem.EncodeToMemory(&pem.Block{ 87 | Type: "PUBLIC KEY", 88 | Bytes: pubEnc, 89 | }) 90 | 91 | actor.PublicKey = vocab.PublicKey{ 92 | ID: vocab.IRI(fmt.Sprintf("%s#main", iri)), 93 | Owner: iri, 94 | PublicKeyPem: string(pubEncoded), 95 | } 96 | 97 | cl := Client(*actor, st, l.WithContext(lw.Ctx{"log": "client"})) 98 | p := processing.New( 99 | processing.Async, processing.WithIDGenerator(GenerateID), 100 | processing.WithLogger(l.WithContext(lw.Ctx{"log": "processing"})), 101 | processing.WithIRI(actor.ID), processing.WithClient(cl), processing.WithStorage(st), 102 | processing.WithLocalIRIChecker(IRIsContain(vocab.IRIs{actor.ID})), 103 | ) 104 | 105 | followers := vocab.Followers.IRI(actor) 106 | outbox := vocab.Outbox.IRI(actor) 107 | upd := new(vocab.Activity) 108 | upd.Type = vocab.UpdateType 109 | upd.Actor = actor.GetLink() 110 | upd.Object = actor 111 | upd.Published = time.Now().UTC() 112 | upd.To = vocab.ItemCollection{vocab.PublicNS} 113 | upd.CC = vocab.ItemCollection{followers} 114 | 115 | _, err = p.ProcessClientActivity(upd, *actor, outbox) 116 | if err != nil { 117 | return actor, err 118 | } 119 | 120 | m.PrivateKey = pem.EncodeToMemory(&pem.Block{ 121 | Type: "PRIVATE KEY", 122 | Bytes: prvEnc, 123 | }) 124 | 125 | if err = st.SaveMetadata(*m, iri); err != nil { 126 | l.WithContext(lw.Ctx{"key": key, "iri": iri}).Errorf("Unable to save the private key") 127 | return actor, err 128 | } 129 | 130 | return actor, nil 131 | } 132 | 133 | func CreateBlankInstance(o *oni) *vocab.Actor { 134 | blankIRI := vocab.IRI(DefaultURL) 135 | if it, err := o.Storage.Load(blankIRI); err == nil { 136 | if blank, err := vocab.ToActor(it); err == nil { 137 | return blank 138 | } else { 139 | o.Logger.WithContext(lw.Ctx{"err": err.Error()}).Warnf("Invalid type %T for expected blank actor", it) 140 | } 141 | } 142 | 143 | blank, err := o.CreateActor(blankIRI, "", false) 144 | if err != nil { 145 | o.Logger.WithContext(lw.Ctx{"err": err.Error()}).Warnf("Unable to create Actor") 146 | } 147 | return blank 148 | } 149 | 150 | func checkIRIResolvesLocally(iri vocab.IRI) (*net.TCPAddr, error) { 151 | uu, err := iri.URL() 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | host := uu.Host 157 | if strings.LastIndexByte(host, ':') < 0 { 158 | if uu.Scheme == "https" { 159 | host += ":443" 160 | } else { 161 | host += ":80" 162 | } 163 | } 164 | return net.ResolveTCPAddr("tcp", host) 165 | } 166 | 167 | func Oni(initFns ...optionFn) *oni { 168 | o := new(oni) 169 | 170 | for _, fn := range initFns { 171 | fn(o) 172 | } 173 | 174 | if opener, ok := o.Storage.(interface{ Open() error }); ok { 175 | if err := opener.Open(); err != nil { 176 | o.Logger.WithContext(lw.Ctx{"err": err.Error()}).Errorf("Unable to open storage") 177 | return o 178 | } 179 | } 180 | 181 | if len(o.a) == 0 { 182 | if blank := CreateBlankInstance(o); blank != nil { 183 | o.a = append(o.a, *blank) 184 | } 185 | } 186 | localURLs := make(vocab.IRIs, 0, len(o.a)) 187 | for i, act := range o.a { 188 | it, err := o.Storage.Load(act.GetLink()) 189 | if err != nil { 190 | o.Logger.WithContext(lw.Ctx{"err": err, "id": act.GetLink()}).Errorf("Unable to find Actor") 191 | continue 192 | } 193 | actor, err := vocab.ToActor(it) 194 | if err != nil || actor == nil { 195 | o.Logger.WithContext(lw.Ctx{"err": err, "id": act.GetLink()}).Errorf("Unable to load Actor") 196 | continue 197 | } 198 | _ = localURLs.Append(actor.GetLink()) 199 | 200 | if err = o.CreateOAuth2ClientIfMissing(actor.ID, DefaultOAuth2ClientPw); err != nil { 201 | o.Logger.WithContext(lw.Ctx{"err": err, "id": actor.ID}).Errorf("Unable to save OAuth2 Client") 202 | } 203 | 204 | if actor.PublicKey.ID == "" { 205 | iri := actor.ID 206 | if actor, err = o.UpdateActorKey(actor); err != nil { 207 | o.Logger.WithContext(lw.Ctx{"err": err, "id": iri}).Errorf("Unable to generate Private Key") 208 | } 209 | } 210 | 211 | if actor != nil { 212 | o.a[i] = *actor 213 | } 214 | } 215 | 216 | o.setupRoutes(o.a) 217 | return o 218 | } 219 | 220 | func WithLogger(l lw.Logger) optionFn { 221 | return func(o *oni) { o.Logger = l } 222 | } 223 | 224 | func LoadActor(items ...vocab.Item) optionFn { 225 | a := make([]vocab.Actor, 0) 226 | for _, it := range items { 227 | if act, err := vocab.ToActor(it); err == nil { 228 | a = append(a, *act) 229 | continue 230 | } 231 | if vocab.IsIRI(it) { 232 | a = append(a, DefaultActor(it.GetLink())) 233 | } 234 | } 235 | return Actor(a...) 236 | } 237 | 238 | func Actor(a ...vocab.Actor) optionFn { 239 | return func(o *oni) { 240 | o.a = a 241 | } 242 | } 243 | 244 | func ListenOn(listen string) optionFn { 245 | return func(o *oni) { 246 | o.Listen = listen 247 | } 248 | } 249 | 250 | func emptyLogFn(_ string, _ ...any) {} 251 | 252 | func WithStoragePath(st string) optionFn { 253 | conf := storage.Config{Path: st, UseIndex: false} 254 | 255 | return func(o *oni) { 256 | o.StoragePath = st 257 | if o.Logger != nil { 258 | conf.Logger = o.Logger 259 | } 260 | o.Logger.WithContext(lw.Ctx{"path": st}).Debugf("Using storage") 261 | st, err := storage.New(conf) 262 | if err != nil { 263 | o.Logger.WithContext(lw.Ctx{"err": err.Error()}).Errorf("Unable to initialize storage") 264 | return 265 | } 266 | o.Storage = st 267 | } 268 | } 269 | 270 | func iris(list ...vocab.Actor) vocab.IRIs { 271 | urls := make(vocab.ItemCollection, 0, len(list)) 272 | for _, a := range list { 273 | urls = append(urls, a) 274 | } 275 | return urls.IRIs() 276 | } 277 | 278 | // Run is the wrapper for starting the web-server and handling signals 279 | func (o *oni) Run(c context.Context) error { 280 | // Create a deadline to wait for. 281 | ctx, cancelFn := context.WithCancel(c) 282 | 283 | sockType := "" 284 | setters := []w.SetFn{w.Handler(o.m)} 285 | 286 | if os.Getenv("LISTEN_FDS") != "" { 287 | sockType = "Systemd" 288 | setters = append(setters, w.OnSystemd()) 289 | } else if filepath.IsAbs(o.Listen) { 290 | dir := filepath.Dir(o.Listen) 291 | if _, err := os.Stat(dir); err == nil { 292 | sockType = "socket" 293 | setters = append(setters, w.OnSocket(o.Listen)) 294 | defer func() { _ = os.RemoveAll(o.Listen) }() 295 | } 296 | } else { 297 | sockType = "TCP" 298 | setters = append(setters, w.OnTCP(o.Listen)) 299 | } 300 | logCtx := lw.Ctx{ 301 | "version": Version, 302 | "socket": o.Listen, 303 | "hosts": iris(o.a...), 304 | } 305 | if sockType != "" { 306 | logCtx["socket"] = o.Listen + "[" + sockType + "]" 307 | } 308 | 309 | // Get start/stop functions for the http server 310 | srvRun, srvStop := w.HttpServer(setters...) 311 | if o.Logger != nil { 312 | o.Logger.WithContext(logCtx).Infof("Started") 313 | } 314 | 315 | stopFn := func(ctx context.Context) { 316 | if closer, ok := o.Storage.(interface{ Close() }); ok { 317 | closer.Close() 318 | } 319 | err := srvStop(ctx) 320 | if o.Logger != nil { 321 | ll := o.Logger.WithContext(logCtx) 322 | if err != nil { 323 | ll.Errorf("%+v", err) 324 | } else { 325 | ll.Infof("Stopped") 326 | } 327 | } 328 | cancelFn() 329 | } 330 | defer stopFn(ctx) 331 | 332 | err := w.RegisterSignalHandlers(w.SignalHandlers{ 333 | syscall.SIGHUP: func(_ chan<- error) { 334 | if o.Logger != nil { 335 | o.Logger.Debugf("SIGHUP received, reloading configuration") 336 | } 337 | }, 338 | syscall.SIGUSR1: func(_ chan<- error) { 339 | InMaintenanceMode = !InMaintenanceMode 340 | if o.Logger != nil { 341 | o.Logger.WithContext(lw.Ctx{"maintenance": InMaintenanceMode}).Debugf("SIGUSR1 received") 342 | } 343 | }, 344 | syscall.SIGINT: func(exit chan<- error) { 345 | if o.Logger != nil { 346 | o.Logger.Debugf("SIGINT received, stopping") 347 | } 348 | exit <- nil 349 | }, 350 | syscall.SIGTERM: func(exit chan<- error) { 351 | if o.Logger != nil { 352 | o.Logger.Debugf("SIGTERM received, force stopping") 353 | } 354 | exit <- nil 355 | }, 356 | syscall.SIGQUIT: func(exit chan<- error) { 357 | if o.Logger != nil { 358 | o.Logger.Debugf("SIGQUIT received, force stopping with core-dump") 359 | } 360 | cancelFn() 361 | exit <- nil 362 | }, 363 | }).Exec(ctx, srvRun) 364 | if o.Logger != nil { 365 | o.Logger.Infof("Shutting down") 366 | } 367 | return err 368 | } 369 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "MIT", 3 | "dependencies": { 4 | "@ctrl/tinycolor": "^4.1.0", 5 | "color.js": "^1.2.0", 6 | "lit": "^3.3.0", 7 | "lit-html": "^3.3.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /prod.go: -------------------------------------------------------------------------------- 1 | //go:build !dev 2 | 3 | package oni 4 | 5 | import "github.com/go-ap/errors" 6 | 7 | func init() { 8 | errors.IncludeBacktrace = false 9 | } 10 | 11 | var IsDev = false 12 | -------------------------------------------------------------------------------- /render.go: -------------------------------------------------------------------------------- 1 | package oni 2 | 3 | import ( 4 | "html/template" 5 | "strings" 6 | 7 | vocab "github.com/go-ap/activitypub" 8 | "github.com/go-ap/errors" 9 | json "github.com/go-ap/jsonld" 10 | "github.com/mariusor/render" 11 | ) 12 | 13 | var ( 14 | defaultRenderOptions = render.Options{ 15 | Directory: "templates", 16 | Layout: "main", 17 | Extensions: []string{".html"}, 18 | FileSystem: TemplateFS, 19 | IsDevelopment: false, 20 | DisableHTTPErrorRendering: true, 21 | Funcs: []template.FuncMap{{ 22 | "HTMLAttr": func(n vocab.NaturalLanguageValues) template.HTMLAttr { 23 | return template.HTMLAttr(n.First().Value) 24 | }, 25 | "HTML": func(n vocab.NaturalLanguageValues) template.HTML { 26 | return template.HTML(n.First().Value) 27 | }, 28 | "oniType": func(i any) template.HTML { 29 | switch it := i.(type) { 30 | case vocab.Item: 31 | t := it.GetType() 32 | switch { 33 | case vocab.ActorTypes.Contains(t): 34 | return "actor" 35 | case vocab.ActivityTypes.Contains(t), vocab.IntransitiveActivityTypes.Contains(t): 36 | return "activity" 37 | case vocab.CollectionTypes.Contains(t): 38 | return "collection" 39 | case t == "": 40 | return "tag" 41 | default: 42 | return template.HTML(strings.ToLower(string(t))) 43 | } 44 | case vocab.IRI: 45 | return "iri" 46 | case vocab.NaturalLanguageValues: 47 | return "natural-language-values" 48 | case vocab.LangRefValue: 49 | return "natural-language-value" 50 | case vocab.LangRef: 51 | return "value" 52 | case error: 53 | default: 54 | return "error" 55 | } 56 | return "error" 57 | }, 58 | "JSON": func(it any) template.HTMLAttr { 59 | var res []byte 60 | switch o := it.(type) { 61 | case vocab.Item: 62 | res, _ = vocab.MarshalJSON(o) 63 | case vocab.NaturalLanguageValues: 64 | res, _ = o.MarshalJSON() 65 | default: 66 | res, _ = json.Marshal(o) 67 | } 68 | return template.HTMLAttr(res) 69 | }, 70 | "HTTPErrors": errors.HttpErrors, 71 | }}, 72 | } 73 | 74 | renderOptions = render.HTMLOptions{} 75 | ren = render.New(defaultRenderOptions) 76 | ) 77 | -------------------------------------------------------------------------------- /src/css/main.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | @import "reset.css"; 3 | 4 | :root { 5 | --bg-color: #232627; 6 | --fg-color: #EFF0F1; 7 | --link-color: #1E90FF; /* dodgerblue */ 8 | --link-visited-color: #9370DB; /* mediumpurple */ 9 | --link-active-color: var(--link-visited-color); 10 | --accent-color: var(--fg-color); 11 | } 12 | 13 | @media (prefers-color-scheme: light) { 14 | :root { 15 | --bg-color: #EFF0F1; 16 | --fg-color: #232627; 17 | --link-color: #0000CD; /* mediumblue */ 18 | --link-visited-color: #663399; /* rebeccapurple */ 19 | --link-active-color: var(--link-visited-color); 20 | --accent-color: var(--fg-color); 21 | } 22 | } 23 | 24 | @media (min-width: 2201px) { 25 | :root { 26 | font-size: .92vw; 27 | } 28 | } 29 | 30 | @media (max-width: 2200px) { 31 | :root { 32 | font-size: .96vw; 33 | } 34 | } 35 | 36 | @media (max-width: 1800px) { 37 | :root { 38 | font-size: 1.16vw; 39 | } 40 | } 41 | 42 | @media (max-width: 1500px) { 43 | :root { 44 | font-size: 1.4vw; 45 | } 46 | } 47 | 48 | @media (max-width: 1200px) { 49 | :root { 50 | /*max-width: unset;*/ 51 | font-size: 1.6vw; 52 | } 53 | } 54 | 55 | @media (max-width: 1050px) { 56 | :root { 57 | font-size: 1.8vw; 58 | } 59 | } 60 | 61 | @media (max-width: 948px) { 62 | :root { 63 | font-size: 2vw; 64 | } 65 | } 66 | 67 | @media (max-width: 860px) { 68 | :root { 69 | font-size: 2.2vw; 70 | } 71 | } 72 | 73 | @media (max-width: 768px) { 74 | :root { 75 | font-size: 2.6vw; 76 | } 77 | } 78 | 79 | @media (max-width: 576px) { 80 | :root { 81 | font-size: 2.8vw; 82 | } 83 | } 84 | 85 | @media (max-width: 480px) { 86 | :root { 87 | font-size: 4vw; 88 | } 89 | } 90 | 91 | @media (max-width: 400px) { 92 | :root { 93 | font-size: 4.8vw; 94 | } 95 | } 96 | 97 | body { 98 | margin: 0 0 auto 0; 99 | color: var(--fg-color); 100 | background-color: var(--bg-color); 101 | min-height: 100vh; 102 | min-width: 80vw; 103 | max-width: 100vw; 104 | align-items: start; 105 | display: grid; 106 | font-family: sans-serif; 107 | line-height: 1.8em; 108 | } 109 | 110 | a { 111 | color: var(--link-color); 112 | } 113 | 114 | a:hover { 115 | text-shadow: 0 0 1rem var(--accent-color), 0 0 .3rem var(--bg-color); 116 | } 117 | 118 | a:visited { 119 | color: var(--link-visited-color); 120 | } 121 | 122 | a:active { 123 | color: var(--link-active-color); 124 | } 125 | 126 | a:has(oni-natural-language-values) { 127 | text-decoration: none; 128 | } 129 | 130 | body > footer { 131 | padding: .8rem 0; 132 | width: auto; 133 | align-self: end; 134 | text-align: end; 135 | } 136 | 137 | body > footer ul { 138 | margin: 0; 139 | padding: 0; 140 | display: inline; 141 | list-style: none; 142 | } 143 | 144 | body > footer ul li { 145 | display: inline-block; 146 | } 147 | 148 | oni-icon { 149 | fill: currentColor; 150 | } 151 | 152 | p { 153 | margin: unset; 154 | } 155 | 156 | oni-natural-language-values, oni-icon { 157 | display: inline-block; 158 | } 159 | 160 | oni-main { 161 | max-width: 100vw; 162 | } 163 | 164 | oni-main > * { 165 | display: block; 166 | margin: 0 1rem; 167 | } 168 | 169 | oni-main > oni-actor { 170 | margin: 0; 171 | width: 100%; 172 | max-width: 100%; 173 | } 174 | 175 | oni-main > * { 176 | display: flex; 177 | flex-direction: column; 178 | max-width: 90cqw; 179 | width: 82ch; 180 | margin: 1rem auto auto; 181 | } 182 | -------------------------------------------------------------------------------- /src/css/reset.css: -------------------------------------------------------------------------------- 1 | /* 2 | Josh's Custom CSS Reset 3 | https://www.joshwcomeau.com/css/custom-css-reset/ 4 | */ 5 | 6 | *, *::before, *::after { 7 | box-sizing: border-box; 8 | } 9 | 10 | html, body { 11 | margin: 0; 12 | } 13 | 14 | body { 15 | line-height: 1.5; 16 | } 17 | 18 | img, picture, video, canvas, svg { 19 | display: block; 20 | max-width: 100%; 21 | } 22 | 23 | input, button, textarea, select { 24 | font: inherit; 25 | } 26 | 27 | p, h1, h2, h3, h4, h5, h6 { 28 | overflow-wrap: break-word; 29 | } 30 | 31 | p { 32 | text-wrap: pretty; 33 | } 34 | h1, h2, h3, h4, h5, h6 { 35 | text-wrap: balance; 36 | } 37 | 38 | #root, #__next { 39 | isolation: isolate; 40 | } 41 | -------------------------------------------------------------------------------- /src/icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/js/activity-pub-activity.jsx: -------------------------------------------------------------------------------- 1 | import {html, nothing} from "lit"; 2 | import {ActivityPubObject} from "./activity-pub-object"; 3 | import {until} from "lit-html/directives/until.js"; 4 | import {ObjectTypes, ActorTypes, ActivityPubItem} from "./activity-pub-item"; 5 | import {unsafeHTML} from "lit-html/directives/unsafe-html.js"; 6 | import {map} from "lit-html/directives/map.js"; 7 | 8 | export class ActivityPubActivity extends ActivityPubObject { 9 | static styles = [ 10 | ActivityPubObject.styles, 11 | ]; 12 | 13 | constructor(it) { 14 | super(it); 15 | this.showMetadata = true; 16 | } 17 | 18 | async renderActor() { 19 | if (!this.it.hasOwnProperty('actor')) return nothing; 20 | 21 | let act = await this.dereferenceProperty('actor'); 22 | if (act === null) { 23 | return nothing; 24 | } 25 | 26 | this.it.actor = new ActivityPubItem(act); 27 | return this.it.actor.getName(); 28 | } 29 | 30 | async renderObject(showMetadata) { 31 | if (!this.it.hasOwnProperty('object')) return nothing; 32 | let raw = await this.dereferenceProperty('object'); 33 | if (raw === null) { 34 | return nothing; 35 | } 36 | if (!Array.isArray(raw)) { 37 | raw = [raw]; 38 | } 39 | 40 | const actor = this.it.hasOwnProperty('actor')? this.it.actor : null; 41 | return html`${map(raw, function (ob) { 42 | if (!ob.hasOwnProperty('attributedTo')) { 43 | ob.attributedTo = actor; 44 | } 45 | if (!ob.hasOwnProperty('type')) { 46 | return html``; 47 | } 48 | if (ActorTypes.indexOf(ob.type) >= 0) { 49 | return html``; 50 | } 51 | if (ObjectTypes.indexOf(ob.type) >= 0) { 52 | return until(ActivityPubObject.renderByType(ob, showMetadata), html`Loading`); 53 | } 54 | return unsafeHTML(``); 55 | })}` 56 | } 57 | 58 | render() { 59 | if (!ActivityPubActivity.validForRender(this.it)) return nothing; 60 | 61 | return html` 62 | ${until(this.renderObject(false))} 63 | ${unsafeHTML(``)} 64 | 65 | `; 66 | } 67 | } 68 | 69 | ActivityPubActivity.validForRender = function (it) { 70 | return (it.type === 'Create') && it.hasOwnProperty('object'); 71 | } 72 | -------------------------------------------------------------------------------- /src/js/activity-pub-actor.jsx: -------------------------------------------------------------------------------- 1 | import {css, html, nothing} from "lit"; 2 | import {ActivityPubObject} from "./activity-pub-object"; 3 | import {activity, loadPalette} from "./utils"; 4 | import {ActivityPubItem} from "./activity-pub-item"; 5 | import {until} from "lit-html/directives/until.js"; 6 | import {TinyColor} from "@ctrl/tinycolor"; 7 | import {unsafeHTML} from "lit-html/directives/unsafe-html.js"; 8 | 9 | const tc = (c) => new TinyColor(c) 10 | 11 | export class ActivityPubActor extends ActivityPubObject { 12 | static styles = [css` 13 | :host header { 14 | padding: 1rem; 15 | display: flex; 16 | justify-content: start; 17 | align-items: flex-end; 18 | justify-items: start; 19 | column-gap: 1.4rem; 20 | } 21 | header section { 22 | display: flex; 23 | flex-direction: column; 24 | flex-wrap: nowrap; 25 | align-content: center; 26 | justify-content: center; 27 | align-items: flex-start; 28 | } 29 | section h1, section h2 { 30 | margin: .2rem 0; 31 | } 32 | section h2 { 33 | font-weight: 300; 34 | } 35 | section h1 a oni-natural-language-values { 36 | color: var(--accent-color); 37 | text-shadow: 0 0 1rem var(--accent-color), 0 0 .3rem var(--bg-color); 38 | } 39 | header > a svg { 40 | color: var(--accent-color); 41 | } 42 | header > a img, header > a svg { 43 | border: .1vw solid var(--accent-color); 44 | border-radius: 0 1.6em 1.6em 1.6em; 45 | shape-outside: margin-box; 46 | box-shadow: 0 0 1rem var(--accent-color), 0 0 .3rem var(--bg-color); 47 | background-color: color-mix(in srgb, var(--accent-color), transparent 80%); 48 | max-height: 10em; 49 | max-width: 10em; 50 | margin-bottom: -.4rem; 51 | } 52 | section ul { 53 | display: inline-block; 54 | margin: 0.3rem 0 0 -1.2rem; 55 | padding: 0.3rem 1.4rem; 56 | border-radius: 1.6em; 57 | background-color: color-mix(in srgb, var(--accent-color), transparent 80%); 58 | } 59 | @media(max-width: 480px) { 60 | :host header { 61 | display: block; 62 | width: auto; 63 | } 64 | :host header h1 { 65 | margin-top: 1rem; 66 | } 67 | section ul { 68 | display: none; 69 | } 70 | } 71 | section ul a, section ul a:visited, section ul a:active { 72 | color: var(--accent-color); 73 | text-shadow: 0 0 1rem var(--bg-color), 0 0 .3rem var(--accent-color); 74 | } 75 | section ul li { 76 | list-style: none; 77 | display: inline-block; 78 | margin-right: .8rem; 79 | } 80 | :host aside small::before { 81 | content: "("; 82 | } 83 | :host aside small::after { 84 | content: ")"; 85 | } 86 | a[target=external] { 87 | font-size: .9rem; 88 | font-weight: light; 89 | } 90 | :host oni-natural-language-values[name=content] { 91 | display: block; 92 | margin: 0 1rem; 93 | } 94 | :host oni-natural-language-values[name=summary] { 95 | font-size: .8em; 96 | } 97 | `,ActivityPubObject.styles]; 98 | 99 | constructor(it) { 100 | super(it); 101 | 102 | this.addEventListener('content.change', this.updateSelf) 103 | } 104 | 105 | async updateSelf(e) { 106 | e.stopPropagation(); 107 | 108 | const outbox = this.it.getOutbox(); 109 | 110 | if (!outbox || !this.authorized) return; 111 | let headers = {}; 112 | if (this.authorized) { 113 | const auth = this._auth.authorization; 114 | headers.Authorization = `${auth?.token_type} ${auth?.access_token}`; 115 | } 116 | 117 | const it = this.it; 118 | const prop = e.detail.name; 119 | 120 | it[prop] = e.detail.content; 121 | 122 | const update = { 123 | type: "Update", 124 | actor: this.it.iri(), 125 | object: it, 126 | } 127 | 128 | activity(outbox, update, headers) 129 | .then(response => { 130 | response.json().then((it) => this.it = new ActivityPubItem(it)); 131 | }).catch(console.error); 132 | } 133 | 134 | renderOAuth() { 135 | const endPoints = this.it.getEndPoints(); 136 | if (!endPoints.hasOwnProperty('oauthAuthorizationEndpoint')) { 137 | return nothing; 138 | } 139 | if (!endPoints.hasOwnProperty('oauthTokenEndpoint')) { 140 | return nothing; 141 | } 142 | const authURL = new URL(endPoints.oauthAuthorizationEndpoint) 143 | const tokenURL = endPoints.oauthTokenEndpoint; 144 | 145 | return html` 146 | `; 150 | } 151 | 152 | renderIcon() { 153 | const icon = this.it.getIcon(); 154 | if (!icon) { 155 | return nothing; 156 | } 157 | if (typeof icon == 'string') { 158 | return html`icon`; 159 | } else { 160 | const url = icon.id || icon.url; 161 | if (url) { 162 | return html`icon`; 163 | } 164 | const cont = new ActivityPubItem(icon).getContent().at(0); 165 | if (cont.length > 0) { 166 | try { 167 | return unsafeHTML(cont); 168 | } catch (e) { 169 | console.warn(e); 170 | return nothing; 171 | } 172 | } 173 | } 174 | return nothing; 175 | } 176 | 177 | renderUrl() { 178 | let url = this.it.getUrl(); 179 | if (!url) { 180 | return nothing; 181 | } 182 | if (!Array.isArray(url)) { 183 | url = [url]; 184 | } 185 | 186 | return html` 187 | `; 194 | } 195 | 196 | renderPreferredUsername() { 197 | const name = this.it.getPreferredUsername(); 198 | if (name.length === 0) { 199 | return nothing; 200 | } 201 | return html``; 202 | } 203 | 204 | renderSummary() { 205 | const summary = this.it.getSummary(); 206 | if (summary.length === 0) { 207 | return nothing; 208 | } 209 | 210 | return html``; 211 | } 212 | 213 | renderContent() { 214 | const content = this.it.getContent(); 215 | if (content.length === 0) { 216 | return nothing; 217 | } 218 | return html``; 219 | } 220 | 221 | async renderBgImage() { 222 | const palette = await loadPalette(this.it); 223 | if (!palette) { 224 | return nothing; 225 | } 226 | 227 | const col = tc(palette.bgColor); 228 | const haveBgImg = palette.hasOwnProperty('bgImageURL') && palette.bgImageURL.length > 0; 229 | if (!haveBgImg || !col) { 230 | return nothing; 231 | } 232 | 233 | const img = palette.bgImageURL; 234 | return html`:host header { 235 | background-size: cover; 236 | background-clip: padding-box; 237 | background-image: linear-gradient(${col.setAlpha(0.5).toRgbString()}, ${col.setAlpha(1).toRgbString()}), url(${img}); 238 | }`; 239 | } 240 | 241 | async renderPalette() { 242 | const palette = await loadPalette(this.it); 243 | if (!palette) return nothing; 244 | 245 | this.scheduleUpdate(); 246 | return html` 247 | :host { 248 | --bg-color: ${palette.bgColor}; 249 | --fg-color: ${palette.fgColor}; 250 | --link-color: ${palette.linkColor}; 251 | --link-visited-color: ${palette.linkVisitedColor}; 252 | --link-active-color: ${palette.linkActiveColor}; 253 | --accent-color: ${palette.accentColor}; 254 | } 255 | ${until(this.renderBgImage(), nothing)} 256 | `; 257 | } 258 | 259 | collections() { 260 | let collections = super.collections(); 261 | if (this.authorized) { 262 | const inbox = this.it.getInbox(); 263 | if (inbox !== null ) { 264 | collections.push(inbox); 265 | } 266 | const liked = this.it.getLiked(); 267 | if (liked !== null) { 268 | collections.push(liked); 269 | } 270 | const followers = this.it.getFollowers(); 271 | if (followers !== null) { 272 | collections.push(followers); 273 | } 274 | const following = this.it.getFollowing(); 275 | if (following !== null) { 276 | collections.push(following); 277 | } 278 | } 279 | const outbox = this.it.getOutbox(); 280 | if (outbox !== null) { 281 | collections.push(outbox); 282 | } 283 | return collections; 284 | } 285 | 286 | renderCollections(slot) { 287 | slot = slot || html``; 288 | const c = this.collections(); 289 | if (c.length === 0) { 290 | return slot; 291 | } 292 | return html`${slot}`; 293 | }; 294 | 295 | render() { 296 | const style = html``; 297 | 298 | const iri = this.it.iri(); 299 | 300 | //console.info(`rendering and checking authorized: ${this.authorized}`,); 301 | return html`${this.renderOAuth()} 302 | ${style} 303 |
304 | ${this.renderIcon()} 305 |
306 |

${this.renderPreferredUsername()}

307 |

${this.renderSummary()}

308 | 309 |
310 |
311 | 312 | ${this.renderContent()} 313 | `; 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /src/js/activity-pub-audio.jsx: -------------------------------------------------------------------------------- 1 | import {css, html, nothing} from "lit"; 2 | import {ActivityPubObject} from "./activity-pub-object"; 3 | import {when} from "lit-html/directives/when.js"; 4 | 5 | export class ActivityPubAudio extends ActivityPubObject { 6 | static styles = [css` 7 | audio { 8 | max-width: 100%; 9 | max-height: 12vw; 10 | align-self: start; 11 | }`, ActivityPubObject.styles]; 12 | 13 | constructor(it) { 14 | super(it); 15 | } 16 | 17 | render() { 18 | const alt = this.it.getSummary(); 19 | const metadata = this.renderMetadata(); 20 | return html` 21 |
22 | 23 | ${when(alt.length > 0, 24 | () => html`
25 | 26 |
`, 27 | () => nothing 28 | )} 29 |
30 | ${this.renderTag()} 31 | ${metadata != nothing ? html`` : nothing} 32 | `; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/js/activity-pub-collection.jsx: -------------------------------------------------------------------------------- 1 | import {css, html, nothing} from "lit"; 2 | import {ActivityPubObject} from "./activity-pub-object"; 3 | import {ifDefined} from "lit-html/directives/if-defined.js"; 4 | import {ActivityTypes, ActorTypes} from "./activity-pub-item"; 5 | import {unsafeHTML} from "lit-html/directives/unsafe-html.js"; 6 | import {ActivityPubActivity} from "./activity-pub-activity"; 7 | import {until} from "lit-html/directives/until.js"; 8 | 9 | export class ActivityPubCollection extends ActivityPubObject { 10 | static styles = [css` 11 | :host ul, :host ol { 12 | padding: 0; 13 | margin: 0; 14 | list-style: none; 15 | } 16 | :host li { 17 | overflow: hidden; 18 | border-bottom: 1px solid var(--fg-color); 19 | } 20 | `, ActivityPubObject.styles]; 21 | 22 | constructor(it) { 23 | super(it); 24 | } 25 | 26 | renderNext() { 27 | if (this.it.hasOwnProperty("next")) { 28 | return html`Next`; 29 | } 30 | return nothing; 31 | } 32 | 33 | renderPrev() { 34 | if (this.it.hasOwnProperty("prev")) { 35 | return html`Prev`; 36 | } 37 | return nothing; 38 | } 39 | 40 | renderPrevNext() { 41 | const prev = this.renderPrev(); 42 | const next = this.renderNext(); 43 | if (prev === nothing && next === nothing) { 44 | return nothing; 45 | } 46 | return html` 47 | `; 50 | } 51 | 52 | renderItems() { 53 | return html`${this.it.getItems().map(it => { 54 | const type = it.hasOwnProperty('type')? it.type : 'unknown'; 55 | 56 | let renderedItem = unsafeHTML(``); 57 | if (ActivityTypes.indexOf(type) >= 0) { 58 | if (!ActivityPubActivity.validForRender(it)) return nothing; 59 | 60 | renderedItem = html``; 61 | } else if (ActorTypes.indexOf(type) >= 0) { 62 | renderedItem = html`` 63 | } else { 64 | if (!ActivityPubObject.validForRender(it)) return nothing; 65 | 66 | renderedItem = ActivityPubObject.renderByType(it); 67 | } 68 | 69 | return html`
  • ${until(renderedItem, html`Loading`)}
  • ` 70 | })}` 71 | } 72 | 73 | render() { 74 | const collection = () => { 75 | if (this.it.getItems().length === 0) { 76 | return html` 77 |
    Nothing to see here, please move along.
    `; 78 | } 79 | 80 | const list = this.it.type.toLowerCase().includes('ordered') 81 | ? html` 82 |
      ${this.renderItems()}
    ` 83 | : html` 84 | `; 85 | 86 | return html` 87 | ${list} 88 | ${this.renderPrevNext()} 89 | `; 90 | } 91 | return html`${collection()}`; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/js/activity-pub-event.jsx: -------------------------------------------------------------------------------- 1 | import {ActivityPubObject} from "./activity-pub-object"; 2 | import {ActivityPubNote} from "./activity-pub-note"; 3 | import {css, html, nothing} from "lit"; 4 | import {renderDuration, renderTimestamp} from "./utils"; 5 | 6 | export class ActivityPubEvent extends ActivityPubNote { 7 | static styles = [ 8 | css` 9 | dl { 10 | padding-left: 2em; 11 | } 12 | dl dt { font-size: .9em; } 13 | dl dd { margin: auto; margin-right: 2em; } 14 | dl dt, dl dd { 15 | display: inline-block; 16 | } 17 | `, 18 | ActivityPubObject.styles 19 | ]; 20 | 21 | constructor(it) { 22 | super(it); 23 | } 24 | 25 | render() { 26 | const name = this.it.getName().length > 0 ? html`

    ${this.renderName()}

    ` : nothing; 27 | const summary = this.it.getSummary().length > 0 ? html`

    ${this.renderSummary()}

    ` : nothing; 28 | const header = this.it.getName().length+this.it.getSummary().length > 0 ? html`
    ${name}${summary}
    ` : nothing; 29 | 30 | const startTime = renderTimestamp(this.it.getStartTime(), false); 31 | const endTime = renderTimestamp(this.it.getEndTime(), false); 32 | const duration = (this.it.getEndTime() - this.it.getStartTime()) / 1000; 33 | 34 | return html`
    35 | ${header} 36 |
    37 |
    Start time:
    ${startTime}
    38 |
    End time:
    ${endTime}
    39 |
    40 | 41 | ${this.renderContent()} 42 | 43 |
    44 | `; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/js/activity-pub-image.jsx: -------------------------------------------------------------------------------- 1 | import {css, html, nothing} from "lit"; 2 | import {ActivityPubObject} from "./activity-pub-object"; 3 | import {when} from "lit-html/directives/when.js"; 4 | 5 | export class ActivityPubImage extends ActivityPubObject { 6 | static styles = [css` 7 | :host { 8 | padding: 1rem 1px 0; 9 | } 10 | img { 11 | border-radius: .4rem; 12 | border: 1px solid var(--accent-color); 13 | max-width: 100%; 14 | height: auto; 15 | } 16 | img.small { 17 | max-width: 1rem; 18 | max-height: 1rem; 19 | vertical-align: text-top; 20 | } 21 | figure { 22 | margin: 0px auto; 23 | } 24 | figcaption { 25 | position: absolute; 26 | padding: 1rem; 27 | display: flex; 28 | align-items: start; 29 | } 30 | details { 31 | cursor: pointer; 32 | font-size: .8em; 33 | background-color: color-mix(in srgb, black, transparent 60%); 34 | padding: .1rem .4rem; 35 | border-radius: .4rem; 36 | } 37 | summary { 38 | list-style-type: none; 39 | font-variant: small-caps; 40 | font-weight: bold; 41 | } 42 | `, ActivityPubObject.styles]; 43 | 44 | constructor(it) { 45 | super(it); 46 | } 47 | 48 | renderAltText() { 49 | const alt = document.createElement('div'); 50 | alt.innerHTML = this.it.getSummary(); 51 | return alt.innerText.trim(); 52 | } 53 | 54 | renderInline() { 55 | let src = this.it.iri(); 56 | if (!src) { 57 | src = this.it.getUrl(); 58 | } 59 | const alt = this.renderAltText(); 60 | return html`${alt}`; 61 | } 62 | 63 | render() { 64 | let src = this.it.iri(); 65 | if (!src) { 66 | src = this.it.getUrl(); 67 | } 68 | if (this.inline) { 69 | return this.renderInline(); 70 | } 71 | const alt = this.renderAltText(); 72 | const metadata = this.renderMetadata(); 73 | return html` 74 |
    75 | ${when(alt.length > 0, 76 | () => html` 77 |
    78 |
    79 | alt 80 | ${alt} 81 |
    82 |
    `, 83 | () => nothing 84 | )} 85 | ${alt} 86 |
    87 | ${this.renderTag()} 88 | ${metadata !== nothing ? html`` : nothing} 89 | `; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/js/activity-pub-item.jsx: -------------------------------------------------------------------------------- 1 | import {fetchActivityPubIRI} from "./client"; 2 | import {nothing} from "lit"; 3 | 4 | export const ObjectTypes = ['Image', 'Audio', 'Video', 'Note', 'Article', 'Page', 'Document', 'Tombstone', 'Event', 'Mention', '']; 5 | export const ActorTypes = ['Person', 'Group', 'Application', 'Service']; 6 | export const ActivityTypes = ['Create', 'Update', 'Delete', 'Accept', 'Reject', 'TentativeAccept', 'TentativeReject', 'Follow', 'Block', 'Ignore']; 7 | export const CollectionTypes = ['Collection', 'CollectionPage', 'OrderedCollection', 'OrderedCollectionPage']; 8 | 9 | //const itemProperties = ['icon', 'image', 'actor', 'attachment', 'audience', 'attributedTo', 'context', 'generator', 'inReplyTo', 'location', 'preview', 'target', 'result', 'origin', 'instrument', 'object']; 10 | 11 | const objectProperties = ['id', 'type', 'icon', 'image', 'summary', 'name', 'content', 'attachment', 'audience', 'attributedTo', 'context', 'mediaType', 'endTime', 'generator', 'inReplyTo', 'location', 'preview', 'published', 'updated', 'startTime', 'tag', 'to', 'bto', 'cc', 'bcc', 'duration', 'source', 'url', 'replies', 'likes', 'shares']; 12 | const tombstoneProperties = ['deleted', 'formerType']; 13 | const actorProperties = ['preferredUsername', 'publicKey', 'endpoints', 'streams', 'inbox', 'outbox', 'liked', 'shared', 'followers', 'following']; 14 | const activityProperties = ['actor', 'target', 'result', 'origin', 'instrument', 'object']; 15 | const collectionProperties = ['items', 'orderedItems', 'totalItems', 'first', 'last', 'current', 'partOf', 'next', 'prev']; 16 | 17 | export class ActivityPubItem { 18 | id = ''; 19 | type = ''; 20 | 21 | constructor(it) { 22 | if (typeof it === 'string') { 23 | this.id = it; 24 | return; 25 | } 26 | this.loadFromObject(it); 27 | return this; 28 | } 29 | 30 | setProp (k, v) { 31 | this[k] = v; 32 | } 33 | 34 | loadFromObject(it, loaded) { 35 | const setPropIfExists = (p) => { 36 | if (!it.hasOwnProperty(p)) return; 37 | this.setProp(p, it[p]); 38 | }; 39 | objectProperties.forEach(setPropIfExists); 40 | if (this.type === 'Tombstone') { 41 | tombstoneProperties.forEach(setPropIfExists); 42 | } 43 | if (ActorTypes.indexOf(this.type) >= 0) { 44 | actorProperties.forEach(setPropIfExists); 45 | } 46 | if (ActivityTypes.indexOf(this.type) >= 0) { 47 | activityProperties.forEach(setPropIfExists); 48 | } 49 | if (CollectionTypes.indexOf(this.type) >= 0) { 50 | collectionProperties.forEach(setPropIfExists); 51 | } 52 | } 53 | 54 | iri() { 55 | return this.id; 56 | } 57 | 58 | getUrl() { 59 | if (!this.hasOwnProperty('url')) { 60 | this.url = null; 61 | } 62 | return this.url; 63 | } 64 | 65 | getTag() { 66 | if (!this.hasOwnProperty('tag')) { 67 | this.tag = null; 68 | } 69 | return this.tag; 70 | } 71 | 72 | getAttachment() { 73 | if (!this.hasOwnProperty('attachment')) { 74 | this.attachment = null; 75 | } 76 | return this.attachment; 77 | } 78 | 79 | getInbox() { 80 | if (!this.hasOwnProperty('inbox')) { 81 | this.inbox = null; 82 | } 83 | return this.inbox; 84 | } 85 | 86 | getOutbox() { 87 | if (!this.hasOwnProperty('outbox')) { 88 | this.outbox = null; 89 | } 90 | return this.outbox; 91 | } 92 | 93 | getLiked() { 94 | if (!this.hasOwnProperty('liked')) { 95 | this.liked = null; 96 | } 97 | return this.liked; 98 | } 99 | 100 | getLikes() { 101 | if (!this.hasOwnProperty('likes')) { 102 | this.likes = null; 103 | } 104 | return this.likes; 105 | } 106 | 107 | getFollowers() { 108 | if (!this.hasOwnProperty('followers')) { 109 | this.followers = null; 110 | } 111 | return this.followers; 112 | } 113 | 114 | getFollowing() { 115 | if (!this.hasOwnProperty('following')) { 116 | this.following = null; 117 | } 118 | return this.following; 119 | } 120 | 121 | 122 | getDeleted() { 123 | if (!this.hasOwnProperty('deleted')) { 124 | this.deleted = null; 125 | } else { 126 | const d = new Date(); 127 | d.setTime(Date.parse(this.deleted)); 128 | this.deleted = d; 129 | } 130 | return this.deleted; 131 | } 132 | 133 | getRecipients() { 134 | let recipients = []; 135 | if (this.hasOwnProperty('to')) { 136 | recipients = recipients.concat(this.to); 137 | } 138 | if (this.hasOwnProperty('cc')) { 139 | recipients = recipients.concat(this.cc); 140 | } 141 | if (this.hasOwnProperty('bto')) { 142 | recipients = recipients.concat(this.bto); 143 | } 144 | if (this.hasOwnProperty('bcc')) { 145 | recipients = recipients.concat(this.bcc); 146 | } 147 | if (this.hasOwnProperty('audience')) { 148 | recipients = recipients.concat(this.audience); 149 | } 150 | return recipients.flat() 151 | .filter((value, index, array) => array.indexOf(value) === index); 152 | } 153 | 154 | getStartTime() { 155 | if (!this.hasOwnProperty('startTime')) { 156 | return null; 157 | } 158 | const d = new Date(); 159 | d.setTime(Date.parse(this.startTime)); 160 | return d || null; 161 | } 162 | 163 | getEndTime() { 164 | if (!this.hasOwnProperty('endTime')) { 165 | return null; 166 | } 167 | const d = new Date(); 168 | d.setTime(Date.parse(this.endTime)); 169 | return d || null; 170 | } 171 | 172 | getPublished() { 173 | if (!this.hasOwnProperty('published')) { 174 | return null; 175 | } 176 | const d = new Date(); 177 | d.setTime(Date.parse(this.published)); 178 | return d || null; 179 | } 180 | 181 | getUpdated() { 182 | if (!this.hasOwnProperty('updated')) { 183 | return null; 184 | } 185 | const d = new Date(); 186 | d.setTime(Date.parse(this.updated)); 187 | return d || null; 188 | } 189 | 190 | getName() { 191 | if (!this.hasOwnProperty('name')) { 192 | this.name = []; 193 | } 194 | let s = this.name; 195 | if (!Array.isArray(s)) { 196 | s = [s]; 197 | } 198 | return s; 199 | } 200 | 201 | getSummary() { 202 | if (!this.hasOwnProperty('summary')) { 203 | this.summary = []; 204 | } 205 | let s = this.summary; 206 | if (!Array.isArray(s)) { 207 | s = [s]; 208 | } 209 | return s; 210 | } 211 | 212 | getContent() { 213 | if (!this.hasOwnProperty('content')) { 214 | this.content = []; 215 | } 216 | let s = this.content; 217 | if (!Array.isArray(s)) { 218 | s = [s]; 219 | } 220 | return s; 221 | } 222 | 223 | getIcon() { 224 | if (!this.hasOwnProperty('icon')) { 225 | this.icon = null; 226 | } 227 | return this.icon; 228 | } 229 | 230 | getImage() { 231 | if (!this.hasOwnProperty('image')) { 232 | this.image = null; 233 | } 234 | return this.image; 235 | } 236 | 237 | getPreferredUsername() { 238 | if (!this.hasOwnProperty('preferredUsername')) { 239 | this.preferredUsername = []; 240 | } 241 | let s = this.preferredUsername; 242 | if (!Array.isArray(s)) { 243 | s = [s]; 244 | } 245 | return s; 246 | } 247 | 248 | getItems() { 249 | let items = []; 250 | if (this.type.toLowerCase().includes('ordered') && this.hasOwnProperty('orderedItems')) { 251 | items = this['orderedItems']; 252 | } else if (this.hasOwnProperty('items')) { 253 | items = this['items']; 254 | } 255 | return items.sort(sortByPublished); 256 | } 257 | 258 | 259 | getEndPoints() { 260 | if (!this.hasOwnProperty('endpoints')) { 261 | return this.endpoints = {}; 262 | } 263 | return this.endpoints; 264 | } 265 | 266 | static load(it) { 267 | let raw = {}; 268 | if (typeof it === "string") { 269 | try { 270 | raw = JSON.parse(it); 271 | } catch (e) { 272 | raw = it; 273 | } 274 | if (URL.canParse(raw) === true) { 275 | const o = new this({id: raw}); 276 | fetchActivityPubIRI(raw) 277 | .then(value => { 278 | if (typeof value === 'undefined') { console.warn('invalid response received'); return;} 279 | if (!value.hasOwnProperty("id")) { console.warn(`invalid return structure`, value); return; } 280 | if (value.hasOwnProperty("errors")) {console.warn(value.errors);return;} 281 | o.loadFromObject(value); 282 | console.info(`fetched ${raw} loaded object`, o); 283 | }).catch(e => console.warn(e)); 284 | return o; 285 | } 286 | } 287 | if (typeof it === "object") { 288 | raw = it; 289 | } 290 | return new this(raw); 291 | } 292 | } 293 | 294 | function sortByPublished(a, b) { 295 | const aHas = a.hasOwnProperty('published'); 296 | const bHas = b.hasOwnProperty('published'); 297 | if (!aHas && !bHas) { 298 | return (a.id <= b.id) ? 1 : -1; 299 | } 300 | if (aHas && !bHas) return -1; 301 | if (!aHas && bHas) return 1; 302 | return Date.parse(b.published) - Date.parse(a.published); 303 | } 304 | -------------------------------------------------------------------------------- /src/js/activity-pub-note.jsx: -------------------------------------------------------------------------------- 1 | import {css, html, nothing} from "lit"; 2 | import {ActivityPubObject} from "./activity-pub-object"; 3 | import {until} from "lit-html/directives/until.js"; 4 | 5 | export class ActivityPubNote extends ActivityPubObject { 6 | static styles = [css` 7 | :host main { 8 | display: flex; 9 | flex-direction: column; 10 | align-items: center; 11 | } 12 | :host > * { 13 | margin: .1rem; 14 | } 15 | article header h1 { 16 | font-size: 1.32rem; 17 | } 18 | article header h2 { 19 | font-size: 1.16rem; 20 | } 21 | article header h3 { 22 | font-size: 1.1rem; 23 | } 24 | article header h4 { 25 | font-size: 1.08rem; 26 | } 27 | article header h5 { 28 | font-size: 1rem; 29 | } 30 | article header h6 { 31 | font-size: .8rem; 32 | } 33 | p { 34 | margin: 0 .2rem; 35 | } 36 | `, ActivityPubObject.styles]; 37 | 38 | constructor(it) { 39 | super(it); 40 | } 41 | 42 | render() { 43 | const name = this.it.getName().length > 0 ? html`

    ${this.renderName()}

    ` : nothing; 44 | const summary = this.it.getSummary().length > 0 ? html`

    ${this.renderSummary()}

    ` : nothing; 45 | const header = this.it.getName().length+this.it.getSummary().length > 0 ? html`
    ${name}${summary}
    ` : nothing; 46 | 47 | const metadata = this.showMetadata ? html`` : nothing; 48 | return html`
    49 | ${header} 50 | ${this.renderContent()} 51 | ${this.renderTag()} 52 | ${this.renderAttachment()} 53 |
    ${metadata}`; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/js/activity-pub-object.jsx: -------------------------------------------------------------------------------- 1 | import {css, html, LitElement, nothing} from "lit"; 2 | import {fetchActivityPubIRI, isLocalIRI} from "./client.js"; 3 | import {pluralize, renderTimestamp} from "./utils.js"; 4 | import {until} from "lit-html/directives/until.js"; 5 | import {map} from "lit-html/directives/map.js"; 6 | import {ActivityPubItem, ObjectTypes} from "./activity-pub-item"; 7 | import {unsafeHTML} from "lit-html/directives/unsafe-html.js"; 8 | 9 | export class ActivityPubObject extends LitElement { 10 | static styles = css` 11 | :host { 12 | color: var(--fg-color); 13 | } 14 | a { 15 | color: var(--link-color); 16 | } 17 | a:hover { 18 | text-shadow: 0 0 1rem var(--accent-color), 0 0 .3rem var(--bg-color); 19 | } 20 | a:visited { 21 | color: var(--link-visited-color); 22 | } 23 | a:active { 24 | color: var(--link-active-color); 25 | } 26 | a:has(oni-natural-language-values) { 27 | text-decoration: none; 28 | } 29 | p a[rel=mention], p a[rel=tag] { 30 | font-size: .9rem; 31 | font-weight: bold; 32 | } 33 | article > * { 34 | margin: .1rem; 35 | } 36 | article header * { 37 | padding: 0; 38 | margin: 0; 39 | } 40 | article header h1 { 41 | font-size: 1.32rem; 42 | } 43 | article { 44 | display: flex; 45 | flex-direction: column; 46 | } 47 | article header { 48 | align-self: start; 49 | } 50 | :host footer { 51 | align-self: end; 52 | } 53 | figure { 54 | margin-bottom: 0; 55 | margin-left: 0; 56 | position: relative; 57 | max-width: fit-content; 58 | } 59 | footer aside { 60 | font-size: 0.8em; 61 | } 62 | oni-activity, oni-note, oni-event, oni-video, oni-audio, oni-tag { 63 | display: flex; 64 | flex-direction: column; 65 | } 66 | .attachment { 67 | display: flex; 68 | flex-wrap: wrap; 69 | align-content: flex-start; 70 | align-items: flex-start; 71 | } 72 | .tag { 73 | display: inline-block; 74 | margin: 0; 75 | padding: 0; 76 | font-size: .8rem; 77 | line-height: 1rem; 78 | } 79 | .tag ul { 80 | display: inline-flex; 81 | flex-wrap: wrap; 82 | padding: 0; 83 | margin: 0; 84 | } 85 | .tag li { 86 | display: inline-block; 87 | list-style: none; 88 | margin-right: .2rem; 89 | } 90 | .tag oni-tag { 91 | display: inline-block; 92 | } 93 | .attachment > * { 94 | display: inline-block; 95 | margin: 0 .2rem .2rem 0; 96 | max-width: 32%; 97 | } 98 | `; 99 | 100 | static properties = { 101 | it: { 102 | type: ActivityPubItem, 103 | converter: { 104 | toAttribute : (val, typ) => JSON.stringify(val), 105 | fromAttribute : (val, typ) => ActivityPubItem.load(val, this.requestUpdate), 106 | }, 107 | }, 108 | showMetadata: {type: Boolean}, 109 | inline: {type: Boolean}, 110 | }; 111 | 112 | constructor(it, showMetadata) { 113 | super(); 114 | 115 | this.showMetadata = showMetadata; 116 | 117 | const json = this.querySelector('script')?.text; 118 | if (json !== null && this.it === null) { 119 | this.it = ActivityPubItem.load(json); 120 | } 121 | } 122 | 123 | 124 | collections() { 125 | let collections = [] 126 | if (this.it.hasOwnProperty('replies')) { 127 | collections.push(this.it.replies); 128 | } 129 | if (this.it.hasOwnProperty('likes')) { 130 | collections.push(this.it.likes); 131 | } 132 | if (this.it.hasOwnProperty('shares')) { 133 | collections.push(this.it.shares); 134 | } 135 | return collections; 136 | } 137 | 138 | async dereferenceProperty(prop) { 139 | if (!this.it.hasOwnProperty(prop)) { 140 | return null; 141 | } 142 | let it = this.it[prop]; 143 | if (typeof it === 'string') { 144 | it = await fetchActivityPubIRI(it); 145 | } 146 | return it; 147 | } 148 | 149 | async fetchAuthor() { 150 | if (this.it.hasOwnProperty('actor')) { 151 | this.it.actor = await this.dereferenceProperty('actor'); 152 | if (this.it.actor) { 153 | return new ActivityPubItem(this.it.actor); 154 | } 155 | } 156 | if (this.it.hasOwnProperty('attributedTo')) { 157 | this.it.attributedTo = await this.dereferenceProperty('attributedTo'); 158 | if (this.it.attributedTo) { 159 | return new ActivityPubItem(this.it.attributedTo); 160 | } 161 | } 162 | return null; 163 | } 164 | 165 | async renderAuthor() { 166 | let act = await this.fetchAuthor(); 167 | if (!act) return nothing; 168 | 169 | if (!Array.isArray(act)) { 170 | act = [act]; 171 | } 172 | 173 | return html`by ${map(act, function (act, i) { 174 | let username = act.getPreferredUsername(); 175 | if (!isLocalIRI(act.id)) { 176 | username = `${username}@${new URL(act.id).hostname}` 177 | } 178 | return html`` 179 | })}`; 180 | } 181 | 182 | renderTag() { 183 | let tags = this.it.getTag(); 184 | if (!tags) { 185 | return nothing; 186 | } 187 | if (!Array.isArray(tags)) { 188 | tags = [tags]; 189 | } 190 | return html` 191 | `; 196 | } 197 | 198 | showChildren(e) { 199 | const self =e.target; 200 | const show = self.open; 201 | self.querySelectorAll('bandcamp-embed').forEach((it) => { 202 | it.show = show; 203 | }); 204 | } 205 | 206 | renderAttachment() { 207 | let attachment = this.it.getAttachment(); 208 | if (!attachment) { 209 | return nothing; 210 | } 211 | if (!Array.isArray(attachment)) { 212 | attachment = [attachment]; 213 | } 214 | return html` 215 |
    216 | ${pluralize(attachment.length, 'attachment')} 217 | 221 |
    `; 222 | } 223 | 224 | renderBookmark() { 225 | const hasName = this.it.getName().length > 0; 226 | return !hasName ? html`` : nothing 227 | } 228 | 229 | renderMetadata() { 230 | if (!this.showMetadata) return nothing; 231 | if (!this.it.hasOwnProperty("attributedTo") && !this.it.hasOwnProperty('actor')) return nothing; 232 | 233 | const auth = this.renderAuthor(); 234 | let action = 'Published'; 235 | 236 | let published = this.it.getPublished(); 237 | const updated = this.it.getUpdated(); 238 | if (updated && updated > published) { 239 | action = 'Updated'; 240 | published = updated; 241 | } 242 | return html``; 247 | } 248 | 249 | renderName() { 250 | const name = this.it.getName(); 251 | if (name.length === 0) { 252 | return nothing; 253 | } 254 | return html` 255 | 256 | `; 257 | } 258 | 259 | renderContent() { 260 | const content = this.it.getContent(); 261 | if (content.length === 0) { 262 | return nothing; 263 | } 264 | return html``; 265 | } 266 | 267 | renderSummary() { 268 | const summary = this.it.getSummary(); 269 | if (summary.length === 0) { 270 | return nothing; 271 | } 272 | 273 | return html``; 274 | } 275 | 276 | async renderReplyCount() { 277 | if (this.inFocus()) { 278 | return nothing; 279 | } 280 | 281 | const replies = await this.dereferenceProperty('replies'); 282 | if (replies === null) { 283 | return nothing; 284 | } 285 | 286 | if (!replies.hasOwnProperty('totalItems') || replies.totalItems == 0) { 287 | return nothing; 288 | } 289 | 290 | return html` - ${pluralize(replies.totalItems, 'reply')}`; 291 | } 292 | 293 | async renderReplies() { 294 | if (!this.inFocus()) { 295 | return nothing; 296 | } 297 | 298 | if (!this.it.hasOwnProperty('replies')) { 299 | return nothing; 300 | } 301 | const replies = this.dereferenceProperty('replies'); 302 | if (replies === null) { 303 | return nothing; 304 | } 305 | 306 | return html``; 307 | } 308 | 309 | inFocus() { 310 | return this.it.iri() === window.location.href; 311 | } 312 | 313 | render() { 314 | if (this.it == null) { 315 | return nothing; 316 | } 317 | 318 | return html`${until(ActivityPubObject.renderByType(this.it), html`Loading`)}${until(this.renderReplies())}`; 319 | } 320 | } 321 | 322 | ActivityPubObject.renderByMediaType = function (it, inline) { 323 | if (!it?.hasOwnProperty('mediaType')) { 324 | return nothing; 325 | } 326 | 327 | if (it.mediaType.indexOf('image/') === 0) { 328 | return html``; 329 | } 330 | if (it.mediaType.indexOf('text/html') === 0) { 331 | return unsafeHTML(it.content); 332 | } 333 | return html`${it.name}`; 334 | } 335 | 336 | ActivityPubObject.renderByType = async function (it, showMetadata) { 337 | if (it === null) { 338 | return nothing; 339 | } 340 | 341 | if (typeof it === 'string') { 342 | it = await fetchActivityPubIRI(it); 343 | if (it === null) return nothing; 344 | } 345 | 346 | if (!it.hasOwnProperty('type')) { 347 | return html``; 348 | } 349 | 350 | switch (it.type) { 351 | case 'Document': 352 | return ActivityPubObject.renderByMediaType(it); 353 | case 'Video': 354 | return html``; 355 | case 'Audio': 356 | return html``; 357 | case 'Image': 358 | return html``; 359 | case 'Note': 360 | case 'Article': 361 | return html``; 362 | case 'Tombstone': 363 | return html``; 364 | case 'Mention': 365 | return html``; 366 | case 'Event': 367 | return html``; 368 | } 369 | return nothing; 370 | } 371 | 372 | ActivityPubObject.validForRender = function (it) { 373 | return ObjectTypes.indexOf(it.type) > 0; 374 | } 375 | -------------------------------------------------------------------------------- /src/js/activity-pub-tag.jsx: -------------------------------------------------------------------------------- 1 | import {html, nothing} from "lit"; 2 | import {ActivityPubNote} from "./activity-pub-note"; 3 | import {until} from "lit-html/directives/until.js"; 4 | 5 | export class ActivityPubTag extends ActivityPubNote { 6 | static styles = ActivityPubNote.styles; 7 | 8 | constructor(it) { 9 | super(it); 10 | } 11 | 12 | renderNameText() { 13 | const name = document.createElement('div'); 14 | name.innerHTML = this.it.getName(); 15 | return name.innerText.trim(); 16 | } 17 | 18 | render() { 19 | if (this.it == null) { 20 | return nothing; 21 | } 22 | const rel = this.it.type === 'Mention' ? 'mention' : 'tag'; 23 | 24 | if (this.showMetadata) { 25 | const name = html`

    ${this.renderName()}

    `; 26 | const summary = this.it.getSummary().length > 0 ? html`

    ${this.renderSummary()}

    ` : nothing; 27 | const header = this.it.getName().length + this.it.getSummary().length > 0 ? html` 28 |
    ${name}${summary}
    ` : nothing; 29 | 30 | return html` 31 |
    ${header} ${this.renderContent()}
    32 | 33 | ${until(this.renderReplies())}`; 34 | } 35 | return html`${this.renderNameText()}`; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/js/activity-pub-tombstone.jsx: -------------------------------------------------------------------------------- 1 | import {css, html, nothing} from "lit"; 2 | import {ActivityPubObject} from "./activity-pub-object"; 3 | import {relativeDate} from "./utils"; 4 | 5 | export class ActivityPubTombstone extends ActivityPubObject { 6 | static styles = ActivityPubObject.styles; 7 | 8 | constructor(it) { 9 | super(it); 10 | } 11 | 12 | renderDeleted() { 13 | if (this.it.type !== "Tombstone") { 14 | return nothing; 15 | } 16 | const deleted = this.it.getDeleted(); 17 | return html`This ${this.it.formerType} has been deleted ${deleted ? 18 | html` 19 | ` : nothing}`; 23 | } 24 | 25 | render() { 26 | return html`${this.renderDeleted()}` 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/js/activity-pub-video.jsx: -------------------------------------------------------------------------------- 1 | import {css, html, nothing} from "lit"; 2 | import {ActivityPubObject} from "./activity-pub-object"; 3 | import {when} from "lit-html/directives/when.js"; 4 | 5 | export class ActivityPubVideo extends ActivityPubObject { 6 | static styles = [css` 7 | video { 8 | max-width: 100%; 9 | max-height: 12vw; 10 | align-self: start; 11 | }`, ActivityPubObject.styles]; 12 | 13 | constructor(it) { 14 | super(it); 15 | } 16 | 17 | render() { 18 | const alt = this.it.getSummary(); 19 | const metadata = this.renderMetadata(); 20 | return html` 21 |
    22 | 23 | ${when(alt.length > 0, 24 | () => html`
    25 | 26 |
    `, 27 | () => nothing 28 | )} 29 |
    30 | ${this.renderTag()} 31 | ${metadata != nothing ? html`` : nothing} 32 | `; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/js/auth-controller.js: -------------------------------------------------------------------------------- 1 | export class AuthController { 2 | _authorization = {}; 3 | 4 | static _hosts = []; 5 | 6 | static get hosts() { 7 | return this._hosts; 8 | } 9 | 10 | get authorization() { 11 | this._authorization = JSON.parse(localStorage.getItem('authorization')) || {}; 12 | return this._authorization; 13 | } 14 | 15 | set authorization(auth) { 16 | if (auth === null) { 17 | localStorage.removeItem('authorization'); 18 | } 19 | localStorage.setItem('authorization', JSON.stringify(auth)) 20 | for (const host of AuthController.hosts) { 21 | host.requestUpdate(); 22 | } 23 | } 24 | 25 | get authorized() { 26 | return this.authorization.hasOwnProperty('access_token') && this.authorization.hasOwnProperty('token_type') && 27 | this.authorization.access_token.length > 0 && this.authorization.token_type.length > 0; 28 | } 29 | 30 | constructor(host) { 31 | host.addController(this); 32 | AuthController.hosts.push(host); 33 | 34 | // this is a poor man's update on the slot content for the oni-mail oni-natural-language-values name="content" 35 | const content = host.querySelector('oni-natural-language-values[name=content]'); 36 | if (content !== null) { 37 | AuthController.hosts.push(content); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/js/bandcamp-embed.jsx: -------------------------------------------------------------------------------- 1 | import {css, html, LitElement, nothing} from "lit"; 2 | 3 | export class BandCampEmbed extends LitElement { 4 | static styles = [css` 5 | :host { 6 | max-height: 2rlh; 7 | margin: 0 .2rem .4rlh 0; 8 | min-width: 480px; 9 | } 10 | iframe { 11 | min-width: 480px; 12 | } 13 | `] 14 | 15 | static properties = { 16 | url: {type: String}, 17 | show: {type: Boolean}, 18 | } 19 | 20 | constructor() { 21 | super(); 22 | this.show = false; 23 | } 24 | 25 | render() { 26 | if (!this.show || this.url === "") { 27 | return nothing; 28 | } 29 | return html`` 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/js/client.js: -------------------------------------------------------------------------------- 1 | import {authorization} from "./utils"; 2 | 3 | export async function fetchActivityPubIRI(iri) { 4 | let headers = fetchHeaders; 5 | if (isLocalIRI(iri)) { 6 | const auth = authorization(); 7 | if (auth.hasOwnProperty('token_type') && auth.hasOwnProperty('access_token')) { 8 | headers.Authorization = `${auth.token_type} ${auth.access_token}`; 9 | } 10 | } else { 11 | // generate HTTP-signature for the actor 12 | } 13 | headers["Origin"] = window.location.hostname; 14 | console.log(`fetching ${isLocalIRI(iri) ? 'local' : 'remote'} IRI `, iri); 15 | const opts = { 16 | headers: headers, 17 | cache: 'force-cache', 18 | }; 19 | return new Promise((resolve, reject) => { 20 | fetch(iri, opts).then(response => { 21 | if (response.hasOwnProperty("headers") && response.headers["Content-Type"] !== jsonLDContentType) { 22 | reject(`invalid response Content-Type ${response.headers["Content-Type"]}`) 23 | } 24 | if (response.status !== 200) { 25 | reject(`Invalid status received ${response.statusText}`); 26 | } else { 27 | response.json().then(resolve).catch(e => reject(e)); 28 | } 29 | }).catch(e => reject(e)) 30 | }); 31 | } 32 | 33 | const jsonLDContentType = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'; 34 | const fetchHeaders = {Accept: jsonLDContentType}; 35 | 36 | export function isLocalIRI(iri) { 37 | if (typeof iri !== 'string') { 38 | return false; 39 | } 40 | return iri.indexOf(window.location.hostname) > 0; 41 | } 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/js/login-elements.jsx: -------------------------------------------------------------------------------- 1 | import {css, html, LitElement} from "lit"; 2 | import {classMap} from "lit-html/directives/class-map.js"; 3 | import {when} from "lit-html/directives/when.js"; 4 | import {handleServerError, isAuthorized} from "./utils.js"; 5 | import {ref} from "lit-html/directives/ref.js"; 6 | import {AuthController} from "./auth-controller.js"; 7 | 8 | export class LoginDialog extends LitElement { 9 | static styles = css` 10 | dialog[opened] { 11 | display: flex; 12 | margin: auto; 13 | } 14 | dialog { 15 | opacity: 1; 16 | display: none; 17 | position: fixed; 18 | flex-direction: column; 19 | border: 2px outset var(--accent-color); 20 | background-color: var(--bg-color); 21 | padding: 1em; 22 | margin: 1em; 23 | align-content: center; 24 | } 25 | form { 26 | display: flex; 27 | flex-direction: column; 28 | align-items: center; 29 | } 30 | form input { 31 | width: 12rem; 32 | } 33 | form button { 34 | width: 12.4rem; 35 | } 36 | .error { 37 | text-align: center; 38 | color: red; 39 | font-size: .7em; 40 | } 41 | .overlay { 42 | background-color: var(--bg-color); 43 | opacity: .8; 44 | display: none; 45 | position: fixed; 46 | top: 0; 47 | bottom: 0; 48 | left: 0; 49 | right: 0; 50 | } 51 | .opened { 52 | display: block; 53 | } 54 | `; 55 | 56 | static properties = { 57 | opened: {type: Boolean}, 58 | fetched: {type: Boolean}, 59 | authorizeURL: {type: String}, 60 | tokenURL: {type: String}, 61 | error: {type: String}, 62 | } 63 | 64 | _auth = new AuthController(this); 65 | 66 | constructor() { 67 | super() 68 | this.opened = false; 69 | this.fetched = false; 70 | this.error = ""; 71 | } 72 | 73 | close() { 74 | this.opened = false; 75 | 76 | this.dispatchEvent(new CustomEvent('dialog.closed', { 77 | bubbles: true, 78 | })); 79 | } 80 | 81 | login(e) { 82 | e.stopPropagation(); 83 | e.preventDefault(); 84 | 85 | const form = e.target; 86 | const pw = form._pw.value 87 | form._pw.value = ""; 88 | 89 | this.authorizationToken(form.action, pw).then(() => console.info("success authorization")); 90 | } 91 | 92 | async authorizationToken(targetURI, pw) { 93 | const l = new URLSearchParams({_pw: pw}); 94 | 95 | const req = { 96 | method: 'POST', 97 | body: l.toString(), 98 | headers: { 99 | Accept: "application/json", 100 | "Content-Type": "application/x-www-form-urlencoded", 101 | } 102 | }; 103 | this.error = ""; 104 | fetch(targetURI, req) 105 | .then(response => { 106 | response.json().then(value => { 107 | if (response.status === 200) { 108 | console.debug(`Obtained authorization code: ${value.code}`) 109 | this.accessToken(value.code, value.state); 110 | } else { 111 | this.error = handleServerError(value) 112 | } 113 | }).catch(console.error); 114 | }).catch(console.error); 115 | } 116 | 117 | async accessToken(code, state) { 118 | const tokenURL = this.tokenURL; 119 | 120 | const client = window.location.hostname; 121 | const l = new URLSearchParams({ 122 | grant_type: 'authorization_code', 123 | code: code, 124 | state: state, 125 | client_id: client, 126 | }); 127 | 128 | const basicAuth = btoa(`${client}:NotSoSecretPassword`); 129 | const req = { 130 | method: 'POST', 131 | body: l.toString(), 132 | headers: { 133 | Accept: "application/json", 134 | "Content-Type": "application/x-www-form-urlencoded", 135 | Authorization: `Basic ${basicAuth}` 136 | } 137 | }; 138 | 139 | fetch(tokenURL, req) 140 | .then(response => { 141 | response.json().then(value => { 142 | if (response.status === 200) { 143 | this._auth.authorization = value; 144 | this.loginSuccessful(); 145 | } else { 146 | this._auth.authorization = {}; 147 | this.error = handleServerError(value) 148 | } 149 | }).catch(console.error); 150 | }).catch(console.error); 151 | } 152 | 153 | loginSuccessful() { 154 | this.close(); 155 | 156 | this.dispatchEvent(new CustomEvent('logged.in', { 157 | bubbles: true, 158 | composed: true, 159 | })); 160 | } 161 | 162 | async getAuthURL() { 163 | if (this.fetched) { 164 | return; 165 | } 166 | 167 | fetch(this.authorizeURL, { method: "GET", headers: {Accept: "application/json"}}) 168 | .then( cont => { 169 | cont.json().then(login => { 170 | this.authorizeURL = login.authorizeURL; 171 | this.fetched = true; 172 | }).catch(console.error); 173 | }) 174 | .catch(console.error); 175 | 176 | } 177 | 178 | render() { 179 | this.getAuthURL(); 180 | 181 | const setFocus = (pw) => pw && pw.focus(); 182 | 183 | return html` 184 |
    185 | 186 |
    0)})}>${this.error}
    187 |
    188 |
    189 | 190 |
    191 |
    192 | ` 193 | } 194 | } 195 | 196 | export class LoginLink extends LitElement { 197 | static styles = css` 198 | :host { 199 | position: absolute; 200 | top: 1rem; 201 | right: 1rem; 202 | } 203 | `; 204 | 205 | static properties = { 206 | authorizeURL: {type: String}, 207 | tokenURL: {type: String}, 208 | dialogVisible: {type: Boolean}, 209 | loginVisible: {type: Boolean}, 210 | } 211 | 212 | _auth = new AuthController(this); 213 | 214 | constructor() { 215 | super() 216 | this.dialogVisible = false; 217 | this.loginVisible = !isAuthorized(); 218 | } 219 | 220 | showDialog(e) { 221 | e.preventDefault(); 222 | e.stopPropagation(); 223 | 224 | this.dialogVisible = true; 225 | } 226 | 227 | hideDialog(e) { 228 | this.dialogVisible = false; 229 | this.loginVisible = true; 230 | } 231 | 232 | logout() { 233 | this._auth.authorization = null; 234 | 235 | this.loginVisible = true; 236 | this.dispatchEvent(new CustomEvent('logged.out', { 237 | bubbles: true, 238 | composed: true, 239 | })); 240 | } 241 | 242 | render() { 243 | return html` 244 | 267 | `; 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/js/main.jsx: -------------------------------------------------------------------------------- 1 | import {OnReady} from "./utils"; 2 | import {OniMain} from "./oni-main"; 3 | import {ActivityPubActor} from "./activity-pub-actor"; 4 | import {OniCollectionLink, OniCollectionLinks} from "./oni-collection-links"; 5 | import {NaturalLanguageValues} from "./natural-language-values"; 6 | import {ActivityPubActivity} from "./activity-pub-activity"; 7 | import {ActivityPubCollection} from "./activity-pub-collection"; 8 | import {ActivityPubObject} from "./activity-pub-object"; 9 | import {ActivityPubImage} from "./activity-pub-image"; 10 | import {ActivityPubNote} from "./activity-pub-note"; 11 | import {ActivityPubAudio} from "./activity-pub-audio"; 12 | import {ActivityPubVideo} from "./activity-pub-video"; 13 | import {OniIcon} from "./oni-icon"; 14 | import {ActivityPubTombstone} from "./activity-pub-tombstone"; 15 | import {ActivityPubTag} from "./activity-pub-tag"; 16 | import {ActivityPubEvent} from "./activity-pub-event"; 17 | import {BandCampEmbed} from "./bandcamp-embed"; 18 | import {OniErrors} from "./oni-errors"; 19 | import {OniHeader} from "./oni-header"; 20 | 21 | customElements.define('oni-main', OniMain); 22 | customElements.define('oni-errors', OniErrors); 23 | customElements.define('oni-header', OniHeader); 24 | 25 | customElements.define('oni-object', ActivityPubObject); 26 | customElements.define('oni-note', ActivityPubNote); 27 | customElements.define('oni-image', ActivityPubImage); 28 | customElements.define('oni-audio', ActivityPubAudio); 29 | customElements.define('oni-video', ActivityPubVideo); 30 | customElements.define('oni-actor', ActivityPubActor); 31 | customElements.define('oni-collection', ActivityPubCollection); 32 | customElements.define('oni-tombstone', ActivityPubTombstone); 33 | customElements.define('oni-activity', ActivityPubActivity); 34 | customElements.define('oni-tag', ActivityPubTag); 35 | customElements.define('oni-event', ActivityPubEvent); 36 | 37 | customElements.define('oni-natural-language-values', NaturalLanguageValues); 38 | 39 | customElements.define('oni-collection-links', OniCollectionLinks); 40 | customElements.define('oni-collection-link', OniCollectionLink); 41 | 42 | customElements.define('oni-icon', OniIcon); 43 | 44 | // customElements.define('oni-login-link', LoginLink); 45 | // customElements.define('oni-login-dialog', LoginDialog); 46 | 47 | //customElements.define('oni-errors', OniErrors); 48 | customElements.define('bandcamp-embed', BandCampEmbed); 49 | 50 | OnReady(function () { 51 | //console.debug(`Loading ${window.location}`); 52 | 53 | const root = document.documentElement; 54 | if (localStorage.getItem('palette')) { 55 | const palette = JSON.parse(localStorage.getItem('palette')); 56 | root.style.setProperty('--fg-color', palette.fgColor); 57 | root.style.setProperty('--bg-color', palette.bgColor); 58 | root.style.setProperty('--link-color', palette.linkColor); 59 | root.style.setProperty('--link-visited-color', palette.linkVisitedColor); 60 | root.style.setProperty('--link-active-color', palette.linkActiveColor); 61 | root.style.setProperty('--accent-color', palette.accentColor); 62 | } 63 | }); 64 | -------------------------------------------------------------------------------- /src/js/natural-language-values.jsx: -------------------------------------------------------------------------------- 1 | import {css, html, LitElement, nothing} from "lit"; 2 | import {unsafeHTML} from "lit-html/directives/unsafe-html.js"; 3 | import {ActivityPubObject} from "./activity-pub-object"; 4 | 5 | export class NaturalLanguageValues extends LitElement { 6 | static styles = [css` 7 | :host { 8 | display: inline 9 | } 10 | :host p { text-align: justify; } 11 | :host div { display: inline-block; } 12 | :host([name=summary]) p, :host([name=name]) p, :host([name=preferredUsername]) p { 13 | display: inline-block; 14 | margin: 0; 15 | } 16 | aside { 17 | display: inline-block; 18 | border: 1px solid var(--fg-color); 19 | padding: .4rem; 20 | font-size: 0.9rem; 21 | background-color: color-mix(in srgb, var(--accent-color) 20%, transparent); 22 | } 23 | pre { 24 | max-width: 100%; 25 | overflow-x: scroll; 26 | } 27 | `, ActivityPubObject.styles]; 28 | 29 | static properties = { 30 | it: {type: Object}, 31 | name: {type: String}, 32 | }; 33 | 34 | constructor() { 35 | super(); 36 | this.it = ''; 37 | this.name = ''; 38 | } 39 | 40 | value() { 41 | let value; 42 | if (typeof this.it == 'string') { 43 | return this.it; 44 | } 45 | if (typeof this.it == 'object') { 46 | value = this.it.toString(); 47 | if (this.it.hasOwnProperty(this.lang)) { 48 | value = this.it.getProperty(this.lang); 49 | } 50 | } 51 | return value; 52 | } 53 | 54 | render() { 55 | if (!this.it) { return nothing; } 56 | return html`${unsafeHTML(this.value()) ?? nothing}`; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/js/oni-collection-links.jsx: -------------------------------------------------------------------------------- 1 | import {css, html, LitElement, nothing} from "lit"; 2 | import {classMap} from "lit-html/directives/class-map.js"; 3 | import {ActivityPubObject} from "./activity-pub-object"; 4 | 5 | export class OniCollectionLinks extends LitElement { 6 | static styles = css` 7 | :host nav { 8 | display: flex; 9 | justify-content: space-between; 10 | border-bottom: 3px solid var(--accent-color); 11 | } 12 | ::slotted { 13 | align-self: start; 14 | } 15 | :host ul { 16 | margin: 0 .8rem 0; 17 | padding: 0; 18 | align-self: end; 19 | } 20 | :host li { 21 | border-width: 1px; 22 | border-style: solid; 23 | border-color: var(--accent-color); 24 | border-bottom-width: 0; 25 | min-width: 8vw; 26 | text-align: center; 27 | list-style: none; 28 | display: inline-block; 29 | line-height: 2.2rem; 30 | padding: 0 .4rem; 31 | margin: 0 .2rem; 32 | background-color: color-mix(in srgb, var(--accent-color), transparent 80%); 33 | text-shadow: 0 0 1rem var(--bg-color), 0 0 .3rem var(--accent-color); 34 | } 35 | :host li.active { 36 | background-color: var(--accent-color); 37 | } 38 | ` 39 | 40 | static properties = { 41 | it: {type: Array} 42 | } 43 | 44 | constructor() { 45 | super(); 46 | this.it = []; 47 | } 48 | 49 | render() { 50 | if (!Array.isArray(this.it) || this.it.length === 0) return nothing; 51 | return html` 52 | `; 62 | } 63 | } 64 | 65 | const LinkStyle = css` 66 | :host a { 67 | text-transform: capitalize; 68 | text-decoration: none; 69 | color: var(--accent-color); 70 | text-shadow: 0 0 1em var(--accent-color), 0 0 .4em var(--bg-color); 71 | } 72 | :host a.active, :host a:visited.active { 73 | color: var(--bg-color); 74 | text-shadow: 0 0 1em var(--accent-color), 0 0 .4rem var(--bg-color); 75 | } 76 | `; 77 | 78 | export class OniCollectionLink extends ActivityPubObject { 79 | static styles = LinkStyle; 80 | 81 | static properties = ActivityPubObject.properties; 82 | 83 | constructor(it) { 84 | super(it); 85 | } 86 | 87 | label() { 88 | const name = this.it.getName(); 89 | if (name.length > 0) { 90 | return name; 91 | } 92 | const pieces = this.it.iri().split('/'); 93 | return pieces[pieces.length -1]; 94 | } 95 | 96 | renderIcon () { 97 | const icon = this.it.getIcon(); 98 | if (icon) { 99 | return html``; 100 | } 101 | return html``; 102 | } 103 | 104 | render() { 105 | const iri = this.it.iri(); 106 | const label = this.label(); 107 | return html`${this.renderIcon()} ${label}`; 108 | } 109 | } 110 | 111 | -------------------------------------------------------------------------------- /src/js/oni-errors.jsx: -------------------------------------------------------------------------------- 1 | import {css, html, LitElement, nothing} from "lit"; 2 | import {map} from "lit-html/directives/map.js"; 3 | import {when} from "lit-html/directives/when.js"; 4 | 5 | export class OniErrors extends LitElement { 6 | static styles = css` 7 | h2 { 8 | text-align: center; 9 | } 10 | details { 11 | font-size: .8em; 12 | } 13 | details[open] summary::before { 14 | content: "Collapse"; 15 | } 16 | details summary::before { 17 | content: "Expand"; 18 | } 19 | pre { 20 | margin: 0 auto; 21 | } 22 | `; 23 | static properties = { 24 | it: {type: Object}, 25 | }; 26 | 27 | constructor() { 28 | super(); 29 | } 30 | 31 | renderErrorDetails(err) { 32 | return html` 33 | ${this.renderErrorTitle(err)} 34 |
    35 | 36 | ${when(Array.isArray(err.trace), () => html` 37 | ${map(err.trace, tr => html`
    ${tr.function}: ${tr.file}:${tr.line}
    `)} 38 | `)} 39 |
    ` 40 | } 41 | 42 | renderErrorTitle(err) { 43 | return html`

    ${err.status ?? nothing} ${err.message}

    ` 44 | } 45 | 46 | renderError(err) { 47 | return Array.isArray(err.trace) ? this.renderErrorDetails(err) : this.renderErrorTitle(err); 48 | } 49 | 50 | render() { 51 | if (!Array.isArray(this.it)) { 52 | this.it = [this.it]; 53 | } 54 | return html` 55 |
    ${map(this.it, err => this.renderError(err))}
    `; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/js/oni-header.jsx: -------------------------------------------------------------------------------- 1 | import {ActivityPubActor} from "./activity-pub-actor"; 2 | import {css, html, nothing} from "lit"; 3 | import {until} from "lit-html/directives/until.js"; 4 | import {isLocalIRI} from "./client"; 5 | import {ActivityPubObject} from "./activity-pub-object"; 6 | 7 | export class OniHeader extends ActivityPubActor { 8 | 9 | static styles = [ 10 | css` 11 | :host header { 12 | padding-top: .4em; 13 | display: grid; 14 | height: 2.2em; 15 | align-items: end; 16 | } 17 | header a img, header a svg { 18 | max-height: 2.2em; 19 | max-width: 2.2em; 20 | border: .1vw solid var(--accent-color); 21 | border-radius: 0 20% 20% 20%; 22 | shape-outside: margin-box; 23 | box-shadow: 0 0 1rem var(--accent-color), 0 0 .3rem var(--bg-color); 24 | background-color: color-mix(in srgb, var(--accent-color), transparent 80%); 25 | margin-bottom: -.4rem; 26 | } 27 | header a, header a:visited, header a:hover { 28 | color: var(--accent-color); 29 | text-shadow: 0 0 1rem var(--accent-color), 0 0 .3rem var(--bg-color); 30 | } 31 | header a { 32 | min-width: 0; 33 | margin-left: .4em; 34 | text-decoration: none; 35 | display: inline-block; 36 | align-self: start; 37 | } 38 | `, 39 | ActivityPubObject.styles 40 | ]; 41 | constructor(it) { 42 | super(it); 43 | } 44 | 45 | renderIconName() { 46 | let username = this.it.getPreferredUsername(); 47 | const iri = this.it.iri(); 48 | if (!isLocalIRI(iri)) { 49 | username = `${username}@${new URL(iri).hostname}` 50 | } 51 | return html` 52 | ${this.renderIcon()} ${username} 53 | `; 54 | } 55 | 56 | render() { 57 | const iconName = html`${this.renderIconName()}`; 58 | 59 | return html` 60 |
    61 | ${ until(this.renderCollections(iconName), html`
    `)} 62 |
    63 | `; 64 | } 65 | } -------------------------------------------------------------------------------- /src/js/oni-icon.jsx: -------------------------------------------------------------------------------- 1 | import {css, html, LitElement, nothing} from "lit"; 2 | import {unsafeSVG} from "lit-html/directives/unsafe-svg.js"; 3 | 4 | export class OniIcon extends LitElement { 5 | static styles = css` 6 | svg { 7 | max-width: 1em; 8 | max-height: 1.2em; 9 | fill: currentColor; 10 | vertical-align: middle; 11 | margin: 0 .2rem; 12 | } 13 | svg[name=icon-outbox] { 14 | vertical-align: text-bottom; 15 | } 16 | svg[name=icon-clock] { 17 | margin: 0; 18 | margin-right: -.2rem; 19 | } 20 | `; 21 | static properties = {name: {type: String}}; 22 | 23 | constructor() { 24 | super(); 25 | } 26 | 27 | render() { 28 | if (!this.name) return nothing; 29 | return html`${unsafeSVG(``)}` 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/js/oni-main.jsx: -------------------------------------------------------------------------------- 1 | import {html} from "lit"; 2 | import {until} from "lit-html/directives/until.js"; 3 | import {ActivityPubObject} from "./activity-pub-object"; 4 | import {isMainPage, renderColors} from "./utils"; 5 | import {AuthController} from "./auth-controller"; 6 | import {when} from "lit-html/directives/when.js"; 7 | 8 | export class OniMain extends ActivityPubObject { 9 | static styles = []; 10 | static properties = [{ 11 | colors: {type: Array}, 12 | }, ActivityPubObject.properties]; 13 | 14 | _auth = new AuthController(this); 15 | 16 | constructor(it) { 17 | super(it); 18 | } 19 | 20 | get authorized() { 21 | return this._auth.authorized && isMainPage(); 22 | } 23 | 24 | render() { 25 | const colors = html`${until(renderColors(this.it))}` 26 | 27 | return html` 28 | ${when( 29 | !isMainPage(), 30 | () => html``, 31 | )} 32 | 33 | ${colors} 34 | `; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/js/utils.js: -------------------------------------------------------------------------------- 1 | import {TinyColor, readability, mostReadable} from "@ctrl/tinycolor"; 2 | import {average, prominent} from "color.js"; 3 | import {ActivityPubItem} from "./activity-pub-item"; 4 | import {html, nothing} from "lit"; 5 | import {map} from "lit-html/directives/map.js"; 6 | 7 | const tc = (c) => new TinyColor(c); 8 | export const contrast = readability; 9 | 10 | export function prefersDarkTheme() { 11 | return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); 12 | } 13 | 14 | export function OnReady(a) { 15 | 'loading' === document.readyState ? document.addEventListener && document.addEventListener('DOMContentLoaded', a) : a.call() 16 | } 17 | 18 | export function hostFromIRI(iri) { 19 | try { 20 | return (new URL(iri)).host; 21 | } catch (err) { 22 | return ''; 23 | } 24 | } 25 | 26 | export function baseIRI(iri) { 27 | try { 28 | const u = new URL(iri); 29 | u.pathname = '/'; 30 | return u.toString(); 31 | } catch (err) { 32 | return ''; 33 | } 34 | } 35 | 36 | export function pastensify(verb) { 37 | if (typeof verb !== 'string') return verb; 38 | if (verb === 'Undo') { 39 | return 'Reverted'; 40 | } 41 | if (verb === 'Create') { 42 | return 'Published'; 43 | } 44 | if (verb[verb.length - 1] === 'e') return `${verb}d`; 45 | return `${verb}ed`; 46 | } 47 | 48 | function splitCollectionIRI(iri) { 49 | const u = new URL(iri); 50 | const pieces = u.pathname.split('/'); 51 | u.search = ''; 52 | const col = pieces[pieces.length - 1]; 53 | u.pathname = u.pathname.replace(col, ''); 54 | return [u.toString(), col]; 55 | } 56 | 57 | export function isAuthorized() { 58 | const auth = authorization(); 59 | return auth.hasOwnProperty('access_token') && auth.hasOwnProperty('token_type') && 60 | auth.access_token.length > 0 && auth.token_type.length > 0; 61 | } 62 | 63 | export function isMainPage() { 64 | return window.location.pathname === '/'; 65 | } 66 | 67 | export function relativeDuration(seconds) { 68 | const minutes = Math.abs(seconds / 60); 69 | const hours = Math.abs(minutes / 60); 70 | 71 | let val = 0.0; 72 | let unit = ""; 73 | if (hours < 1) { 74 | if (minutes < 1) { 75 | val = seconds; 76 | unit = "second"; 77 | } else { 78 | val = minutes; 79 | unit = "minute"; 80 | } 81 | } else if (hours < 24) { 82 | val = hours; 83 | unit = "hour"; 84 | } else if (hours < 168) { 85 | val = hours / 24; 86 | unit = "day"; 87 | } else if (hours < 672) { 88 | val = hours / 168; 89 | unit = "week"; 90 | } else if (hours < 8760) { 91 | val = hours / 730; 92 | unit = "month"; 93 | } else if (hours < 87600) { 94 | val = hours / 8760; 95 | unit = "year"; 96 | } else if (hours < 876000) { 97 | val = hours / 87600; 98 | unit = "decade"; 99 | } else { 100 | val = hours / 876000; 101 | unit = "century"; 102 | } 103 | return [val, unit]; 104 | } 105 | 106 | export function relativeDate(old) { 107 | const seconds = (Date.now() - Date.parse(old)) / 1000; 108 | if (seconds >= 0 && seconds < 30) { 109 | return "now"; 110 | } 111 | 112 | let when = "ago"; 113 | if (seconds < 0) { 114 | // we're in the future 115 | when = "in the future"; 116 | } 117 | const [val, unit] = relativeDuration(seconds); 118 | 119 | return `${pluralize(val, unit)} ${when}`; 120 | } 121 | 122 | export function pluralize(d, unit) { 123 | d = Math.round(d); 124 | const l = unit.length; 125 | if (l > 2 && unit[l - 1] === 'y' && isCons(unit[l - 2])) { 126 | unit = `${unit.substring(0, l - 1)}ie`; 127 | } 128 | if (d > 1) { 129 | unit = `${unit}s` 130 | } 131 | return `${d} ${unit}`; 132 | } 133 | 134 | function isCons(c) { 135 | function isVowel(v) { 136 | return ['a', 'e', 'i', 'o', 'u'].indexOf(v) >= 0; 137 | } 138 | return !isVowel(c); 139 | } 140 | 141 | export function authorization() { 142 | return JSON.parse(localStorage.getItem('authorization')) || {}; 143 | } 144 | 145 | export function handleServerError(err) { 146 | let errMessage; 147 | if (err.hasOwnProperty('errors')) { 148 | console.error(err.errors); 149 | if (!Array.isArray(err.errors)) { 150 | err.errors = [err.errors]; 151 | } 152 | err.errors.forEach((err) => { 153 | errMessage += ` ${err.message}`; 154 | }) 155 | } else { 156 | console.error(err); 157 | errMessage += err.toString(); 158 | } 159 | return errMessage; 160 | } 161 | 162 | export function activity(outbox, update, extraHeaders = {}, success = () => {}) { 163 | const headers = { 164 | 'Content-Type': 'application/activity+json', 165 | }; 166 | 167 | const req = { 168 | headers: {...headers, ...extraHeaders}, 169 | method: "POST", 170 | body: JSON.stringify(update) 171 | }; 172 | 173 | return fetch(outbox, req) 174 | } 175 | 176 | export function showError(e) { 177 | console.warn(e); 178 | alert(e); 179 | } 180 | 181 | function colorsFromImage (url) { 182 | return prominent(url, {amount: 30, group: 40, format: 'hex', sample: 8}) 183 | } 184 | 185 | const /* filter */ onLightness = (min, max) => (col) => tc(col)?.toHsl()?.l >= (min || 0) 186 | && tc(col)?.toHsl()?.l <= (max || 1); 187 | const /* filter */ onSaturation = (min, max) => (col) => tc(col)?.toHsl()?.s >= (min || 0) 188 | && tc(col)?.toHsl()?.s <= (max || 1); 189 | const /* filter */ onContrastTo = (base, min, max) => (col) => contrast(col, base) >= (min || 0) 190 | && contrast(col, base) <= (max || 21); 191 | const /* filter */ not = (c, diff) => (n) => Math.abs(colorDiff(c, n)) >= (diff || 2); 192 | const /* sort */ byContrastTo = (base) => (a, b) => contrast(b, base) - contrast(a, base); 193 | const /* sort */ bySaturation = (a, b) => tc(b).toHsv().s - tc(a).toHsv().s; 194 | const /* sort */ byDiff = (base) => (a, b) => Math.abs(colorDiff(a, base)) - Math.abs(colorDiff(b, base)); 195 | 196 | function paletteIsValid(palette, imageURL, iconURL) { 197 | return ((!palette.hasOwnProperty('bgImageURL') && imageURL === '') || palette.bgImageURL === imageURL) && 198 | ((!palette.hasOwnProperty('iconURL') && iconURL === '') || palette.iconURL === iconURL) 199 | } 200 | 201 | export async function loadPalette(it) { 202 | const imageURL = apURL(it.getImage()); 203 | const iconURL = apURL(it.getIcon()); 204 | 205 | if (localStorage.getItem('palette')) { 206 | const palette = JSON.parse(localStorage.getItem('palette')); 207 | if (paletteIsValid(palette, imageURL, iconURL)) return palette; 208 | } 209 | const root = document.documentElement; 210 | const style = getComputedStyle(root); 211 | const defaultBgColor = style.getPropertyValue('--bg-color').trim(); 212 | 213 | const palette = { 214 | bgColor: style.getPropertyValue('--bg-color').trim(), 215 | fgColor: style.getPropertyValue('--fg-color').trim(), 216 | accentColor: style.getPropertyValue('--accent-color').trim(), 217 | linkColor: style.getPropertyValue('--link-color').trim(), 218 | linkActiveColor: style.getPropertyValue('--link-active-color').trim(), 219 | linkVisitedColor: style.getPropertyValue('--link-visited-color').trim(), 220 | colorScheme: prefersDarkTheme() ? 'dark' : 'light', 221 | imageColors: [], 222 | iconColors: [], 223 | }; 224 | 225 | let iconColors = []; 226 | let imageColors = []; 227 | let avgColor = defaultBgColor; 228 | 229 | if (imageURL) { 230 | palette.bgImageURL = imageURL; 231 | imageColors = (await colorsFromImage(imageURL));//?.filter(validColors); 232 | avgColor = await average(imageURL, {format: 'hex'}); 233 | } 234 | 235 | if (iconURL) { 236 | palette.iconURL = iconURL; 237 | iconColors = (await colorsFromImage(iconURL));//?.filter(validColors); 238 | if (avgColor) { 239 | avgColor = await average(iconURL, {format: 'hex'}); 240 | } 241 | } 242 | 243 | if (avgColor) { 244 | palette.bgColor = avgColor; 245 | palette.colorScheme = tc(avgColor).isDark() ? 'dark' : 'light'; 246 | 247 | root.style.setProperty('--bg-color', palette.bgColor); 248 | root.style.setProperty('backgroundImage', `linear-gradient(${tc(avgColor).setAlpha(0).toRgb()}, ${tc(avgColor).setAlpha(1).toRgb()}), url(${imageURL});`) 249 | } 250 | 251 | palette.iconColors = iconColors; 252 | 253 | if (iconColors.length > 0) { 254 | console.debug(`loaded icon colors:`, iconColors); 255 | palette.accentColor = getAccentColor(palette, iconColors) || palette.accentColor; 256 | iconColors = iconColors.filter(not(palette.accentColor, 1)); 257 | 258 | palette.linkColor = getAccentColor(palette, iconColors) || palette.linkColor; 259 | iconColors = iconColors.filter(not(palette.linkColor, 1)); 260 | 261 | palette.linkVisitedColor = getClosestColor(palette, iconColors, palette.linkColor) || palette.linkVisitedColor; 262 | iconColors = iconColors.filter(not(palette.linkVisitedColor, 1)); 263 | 264 | palette.linkActiveColor = getClosestColor(palette, iconColors, palette.linkColor) || palette.linkActiveColor; 265 | } 266 | 267 | if (imageColors.length+iconColors.length > 0) { 268 | console.debug(`loaded image colors:`, imageColors); 269 | palette.fgColor = getFgColor(palette, imageColors+iconColors) || palette.fgColor; 270 | root.style.setProperty('--fg-color', palette.fgColor); 271 | } 272 | 273 | localStorage.setItem('palette', JSON.stringify(palette)); 274 | return palette; 275 | } 276 | 277 | function getFgColor(palette, colors) { 278 | colors = colors || []; 279 | 280 | return mostReadable(palette.bgColor, colors, {includeFallbackColors: true})?.toHexString(); 281 | } 282 | 283 | function getClosestColor(palette, colors, color) { 284 | colors = colors || []; 285 | 286 | colors = colors 287 | .filter(onContrastTo(palette.bgColor, 3, 7)) 288 | .sort(byDiff(color)) 289 | .reverse(); 290 | return colors.at(0); 291 | } 292 | 293 | function getAccentColor(palette, colors) { 294 | colors = colors || []; 295 | 296 | const filterColors = (colors) => colors 297 | .filter(onSaturation(0.4)) 298 | .filter(onLightness(0.4, 0.6)); 299 | 300 | let accentColors = colors; 301 | for (let i = 0; i < 10; i++) { 302 | accentColors = filterColors(accentColors); 303 | if (accentColors.length > 0) break; 304 | 305 | colors.forEach((value, index) => { 306 | accentColors[index] = tc(value).saturate().toHexString() 307 | }); 308 | } 309 | if (accentColors.length === 0) { 310 | return ""; 311 | } 312 | return mostReadable(palette.bgColor, accentColors)?.toHexString(); 313 | } 314 | 315 | export function renderColors() { 316 | const palette = JSON.parse(localStorage.getItem('palette')); 317 | 318 | if (!palette) return nothing; 319 | if (!window.location.hostname.endsWith('local')) return nothing; 320 | 321 | const colorMap = (ordered) => html` 322 | ${map(ordered, value => { 323 | const color = mostReadable(value, [palette.bgColor, palette.fgColor]); 324 | return html` 325 |
    326 | 327 | ${value} 328 | : ${colorDiff(value, palette.bgColor).toFixed(2)} 329 | : ${contrast(value, palette.bgColor).toFixed(2)} 330 | : ${contrast(value, palette.fgColor).toFixed(2)} 331 | : ${tc(value).toHsl().h.toFixed(2)} 332 | : ${tc(value).toHsl().s.toFixed(2)} 333 | : ${tc(value).toHsl().l.toFixed(2)} 334 | 335 |
    336 | ` 337 | })} 338 | `; 339 | return html`${colorMap(palette.iconColors)}
    ${colorMap(palette.imageColors)}`; 340 | } 341 | 342 | function apURL(ob) { 343 | if (typeof ob === 'object' && ob !== null) { 344 | ob = new ActivityPubItem(ob); 345 | ob = ob.iri() || ob.getUrl(); 346 | } 347 | return ob 348 | } 349 | 350 | export function renderTimestamp(published, relative = true) { 351 | if (!published) { 352 | return nothing; 353 | } 354 | return html``; 357 | } 358 | 359 | export function renderDuration(seconds) { 360 | if (!seconds) { 361 | return nothing; 362 | } 363 | const [val, unit] = relativeDuration(seconds) 364 | return html`${pluralize(val, unit)}`; 365 | } 366 | 367 | function validColors(value, index, array) { 368 | const notDark = not('#000000',2)(value); 369 | const notLight = not('#ffffff', 2)(value); 370 | return notDark && notLight; 371 | } 372 | 373 | // formulas from : https://www.easyrgb.com/en/math.php 374 | function toXYZ(col) { 375 | col = tc(col)?.toRgb(); 376 | col = { 377 | r: col.r / 255, 378 | g: col.g / 255, 379 | b: col.b / 255, 380 | } 381 | 382 | const convVal = (v) => 100*(v > 0.04045 ? Math.pow((v + 0.055) / 1.055, 2.4) : v / 12.92); 383 | 384 | return { 385 | x: convVal(col.r) * 0.4124 + convVal(col.g) * 0.3576 + convVal(col.b) * 0.1805, 386 | y: convVal(col.r) * 0.2126 + convVal(col.g) * 0.7152 + convVal(col.b) * 0.0722, 387 | z: convVal(col.r) * 0.0193 + convVal(col.g) * 0.1192 + convVal(col.b) * 0.9505, 388 | } 389 | } 390 | 391 | function xyzToLab(col) { 392 | // Data from https://en.wikipedia.org/wiki/Illuminant_D65#Definition using a standard 2° observer 393 | const refX = 95.04; 394 | const refY = 100; 395 | const refZ = 108.88; 396 | 397 | const convVal = (v) => (v > 0.008856) ? Math.pow(v , 1/3) : (7.787 * v) + (16 / 116); 398 | 399 | let x = convVal(col.x / refX); 400 | let y = convVal(col.y / refY); 401 | let z = convVal(col.z / refZ); 402 | 403 | return { 404 | L: (116 * y) - 16, 405 | a: 500 * (x - y), 406 | b: 200 * (y - z), 407 | } 408 | } 409 | 410 | export function colorDiff(c1, c2) { 411 | c1 = xyzToLab(toXYZ(tc(c1)?.toRgb())); 412 | c2 = xyzToLab(toXYZ(tc(c2)?.toRgb())); 413 | return Math.sqrt(Math.pow(c2.a , 2) + Math.pow(c2.b , 2)) - 414 | Math.sqrt(Math.pow(c1.a , 2) + Math.pow(c1.b , 2)) 415 | } 416 | -------------------------------------------------------------------------------- /src/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | 4 | User-agent: search.marginalia.nu 5 | Allow: / 6 | -------------------------------------------------------------------------------- /templates/components/errors.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /templates/components/item.html: -------------------------------------------------------------------------------- 1 | {{ $type := . | oniType }} 2 | 3 | -------------------------------------------------------------------------------- /templates/components/person.html: -------------------------------------------------------------------------------- 1 | {{ $type := . | oniType }} 2 | 3 | -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 | 5 | 6 |
    9 | 10 | Cancel and return 11 |
    12 |
    13 |
    14 | 15 | -------------------------------------------------------------------------------- /templates/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ Title }} 7 | 8 | {{- range $key, $value := URLS }} 9 | 10 | {{- end }} 11 | {{- $curURL := CurrentURL -}} 12 | {{- if ne $curURL "" }} 13 | 14 | {{- end }} 15 | 16 | 17 | 18 | 19 | 20 | {{ yield -}} 21 | 22 | 35 | 36 | 37 | --------------------------------------------------------------------------------