├── .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 = ` Oni `
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` `;
159 | } else {
160 | const url = icon.id || icon.url;
161 | if (url) {
162 | return html` `;
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 |
307 | ${this.renderSummary()}
308 | ${this.renderUrl()}
309 |
310 |
311 | ${ until(this.renderCollections(), html` `)}
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 |
48 | ${ifDefined(prev)} ${ifDefined(next)}
49 | `;
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`` : 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 | ${this.renderAttachment()}
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` `;
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 |
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`` : 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 |
192 | ${tags.map(
193 | value => html`${until(ActivityPubObject.renderByType(value, false), html`Loading`)} `
194 | )}
195 | `;
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 |
218 | ${attachment.map(
219 | value => until(ActivityPubObject.renderByType(value), html`Loading`)
220 | )}
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`
243 | ${action} ${renderTimestamp(published)} ${until(auth)}
244 | ${until(this.renderReplyCount())}
245 | ${this.renderBookmark()}
246 | `;
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``;
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 | ` : 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 |
20 |
21 | ${relativeDate(deleted)}
22 | ` : 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 |
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 |
245 | ${when(
246 | this.loginVisible,
247 | () => html`
248 |
249 |
250 | Sign in
251 |
252 | {this.loginVisible = false}}
258 | >
259 | `,
260 | () => html`
261 |
262 | Sign out
263 |
264 | `
265 | )}
266 |
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 |
53 |
54 |
55 | ${this.it.map(value => html`
56 |
57 |
58 | `
59 | )}
60 |
61 | `;
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(`${this.name} `)}`
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`
355 | ${relative ? relativeDate(published) : published.toLocaleString()}
356 | `;
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 |
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 |
23 |
24 | We must apologise, but this site was created as a reference implementation for a JavasScript client
25 | on top of ActivityPub, and as such, it does not work without it.
26 |
27 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------