Compare commits

..

No commits in common. "main" and "own_design" have entirely different histories.

44 changed files with 342 additions and 2457 deletions

View File

@ -1,11 +1,11 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "/tmp"
tmp_dir = "tmp"
[build]
args_bin = ["web"]
bin = "/tmp/main"
cmd = "go build -buildvcs=false -o /tmp/main owl-blogs/cmd/owl"
bin = "./tmp/main"
cmd = "go build -o ./tmp/main owl-blogs/cmd/owl"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []

View File

@ -1,3 +0,0 @@
e2e_tests/
tmp/
*.db

View File

@ -1,45 +0,0 @@
on:
release:
types: [created]
permissions:
contents: write
packages: write
jobs:
release-linux-amd64:
name: release linux/amd64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.22'
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...
- name: E2E Test
run: |
cd e2e_tests
docker compose -f docker-compose.ci.yml up -d
pip install -r requirements.txt
pytest
- name: Build Release
env:
CGO_ENABLED: 1
GOOS: linux
GOARCH: amd64
GH_TOKEN: ${{ github.token }}
run: |
go build -o owl-linux-amd64 ./cmd/owl
gh release upload ${{github.event.release.tag_name}} owl-linux-amd64

View File

@ -1,38 +0,0 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Go
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.22'
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...
- name: E2E Test
run: |
cd e2e_tests
docker compose -f docker-compose.ci.yml up -d
pip install -r requirements.txt
pytest

3
.gitignore vendored
View File

@ -28,6 +28,3 @@ users/
*.db
tmp/
venv/
*.pyc

View File

@ -2,9 +2,17 @@
# Owl Blogs
Owl-blogs is a blogging software focused on simplicity with IndieWeb and Fediverse support.
A simple web server for blogs generated from Markdown files.
# Usage
**_This project is not yet stable. Expect frequent breaking changes! Only use this if you are willing to regularly adjust your project accordingly._**
## Build
```
CGO_ENABLED=1 go build -o owl ./cmd/owl
```
## Run
@ -27,35 +35,3 @@ To retrieve a list of all commands run:
```
owl -h
```
# Development
## Build
```
CGO_ENABLED=1 go build -o owl ./cmd/owl
```
For development with live reload use `air` ([has to be install first](https://github.com/cosmtrek/air))
## Tests
The project has two test suites; "unit tests" written in go and "end-to-end tests" written in python.
### Unit Tests
```
go test ./...
```
### End-to-End tests
- Start the docker compose setup in the `e2e_tests` directory.
- Install the python dependencies into a virtualenv
```
cd e2e_tests
python3 -m venv venv
. venv/bin/activate
pip install -r requirements.txt
```
- Run the e2e_tests with `pytest`

View File

@ -1,712 +0,0 @@
package app
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"owl-blogs/app/repository"
"owl-blogs/config"
"owl-blogs/domain/model"
entrytypes "owl-blogs/entry_types"
"owl-blogs/interactions"
"owl-blogs/render"
"reflect"
"regexp"
"strconv"
"strings"
"time"
vocab "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
"github.com/go-fed/httpsig"
)
type ActivityPubConfig struct {
PreferredUsername string
PublicKeyPem string
PrivateKeyPem string
}
// Form implements app.AppConfig.
func (cfg *ActivityPubConfig) Form(binSvc model.BinaryStorageInterface) string {
f, _ := render.RenderTemplateToString("forms/ActivityPubConfig", cfg)
return f
}
// ParseFormData implements app.AppConfig.
func (cfg *ActivityPubConfig) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
cfg.PreferredUsername = data.FormValue("PreferredUsername")
cfg.PublicKeyPem = data.FormValue("PublicKeyPem")
cfg.PrivateKeyPem = data.FormValue("PrivateKeyPem")
return nil
}
func (cfg *ActivityPubConfig) PrivateKey() *rsa.PrivateKey {
block, _ := pem.Decode([]byte(cfg.PrivateKeyPem))
privKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
slog.Error("error x509.ParsePKCS1PrivateKey", "err", err)
}
return privKey
}
type ActivityPubService struct {
followersRepo repository.FollowerRepository
configRepo repository.ConfigRepository
interactionRepository repository.InteractionRepository
entryService *EntryService
siteConfigServcie *SiteConfigService
binService *BinaryService
}
func NewActivityPubService(
followersRepo repository.FollowerRepository,
configRepo repository.ConfigRepository,
interactionRepository repository.InteractionRepository,
entryService *EntryService,
siteConfigServcie *SiteConfigService,
binService *BinaryService,
bus *EventBus,
) *ActivityPubService {
service := &ActivityPubService{
followersRepo: followersRepo,
configRepo: configRepo,
interactionRepository: interactionRepository,
entryService: entryService,
binService: binService,
siteConfigServcie: siteConfigServcie,
}
bus.Subscribe(service)
return service
}
func (svc *ActivityPubService) defaultConfig() ActivityPubConfig {
privKey, _ := rsa.GenerateKey(rand.Reader, 2048)
pubKey := privKey.Public().(*rsa.PublicKey)
pubKeyPem := pem.EncodeToMemory(
&pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: x509.MarshalPKCS1PublicKey(pubKey),
},
)
privKeyPrm := pem.EncodeToMemory(
&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
},
)
return ActivityPubConfig{
PreferredUsername: "blog",
PublicKeyPem: string(pubKeyPem),
PrivateKeyPem: string(privKeyPrm),
}
}
func (svc *ActivityPubService) GetApConfig() (ActivityPubConfig, error) {
apConfig := ActivityPubConfig{}
err := svc.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
if err != nil {
println("ERROR IN ACTIVITY PUB CONFIG")
return ActivityPubConfig{}, err
}
if reflect.ValueOf(apConfig).IsZero() {
cfg := svc.defaultConfig()
svc.configRepo.Update(config.ACT_PUB_CONF_NAME, cfg)
return cfg, nil
}
return apConfig, nil
}
func (svc *ActivityPubService) ActorUrl() string {
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
return cfg.FullUrl
}
func (svc *ActivityPubService) MainKeyUri() string {
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
return cfg.FullUrl + "#main-key"
}
func (svc *ActivityPubService) InboxUrl() string {
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
return cfg.FullUrl + "/activitypub/inbox"
}
func (svc *ActivityPubService) OutboxUrl() string {
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
return cfg.FullUrl + "/activitypub/outbox"
}
func (svc *ActivityPubService) FollowersUrl() string {
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
return cfg.FullUrl + "/activitypub/followers"
}
func (svc *ActivityPubService) AcccepId() string {
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
return cfg.FullUrl + "#accept-" + strconv.FormatInt(time.Now().UnixNano(), 16)
}
func (svc *ActivityPubService) HashtagId(hashtag string) string {
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
return cfg.FullUrl + "/tags/" + strings.ReplaceAll(hashtag, "#", "")
}
func (svc *ActivityPubService) ActorName() string {
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
return cfg.Title
}
func (svc *ActivityPubService) ActorIcon() vocab.Image {
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
u := cfg.AvatarUrl
pUrl, _ := url.Parse(u)
parts := strings.Split(pUrl.Path, ".")
fullUrl, _ := url.JoinPath(cfg.FullUrl, u)
return vocab.Image{
Type: vocab.ImageType,
MediaType: vocab.MimeType("image/" + parts[len(parts)-1]),
URL: vocab.IRI(fullUrl),
}
}
func (svc *ActivityPubService) ActorSummary() string {
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
return cfg.SubTitle
}
func (s *ActivityPubService) AddFollower(follower string) error {
return s.followersRepo.Add(follower)
}
func (s *ActivityPubService) RemoveFollower(follower string) error {
return s.followersRepo.Remove(follower)
}
func (s *ActivityPubService) AllFollowers() ([]string, error) {
return s.followersRepo.All()
}
func (s *ActivityPubService) sign(privateKey *rsa.PrivateKey, pubKeyId string, body []byte, r *http.Request) error {
prefs := []httpsig.Algorithm{httpsig.RSA_SHA256}
digestAlgorithm := httpsig.DigestSha256
// The "Date" and "Digest" headers must already be set on r, as well as r.URL.
headersToSign := []string{httpsig.RequestTarget, "host", "date"}
if body != nil {
headersToSign = append(headersToSign, "digest")
}
signer, _, err := httpsig.NewSigner(prefs, digestAlgorithm, headersToSign, httpsig.Signature, 0)
if err != nil {
return err
}
// To sign the digest, we need to give the signer a copy of the body...
// ...but it is optional, no digest will be signed if given "nil"
// If r were a http.ResponseWriter, call SignResponse instead.
err = signer.SignRequest(privateKey, pubKeyId, r, body)
slog.Info("Signed Request", "req", r.Header)
return err
}
func (s *ActivityPubService) GetActor(reqUrl string) (vocab.Actor, error) {
siteConfig := model.SiteConfig{}
apConfig := ActivityPubConfig{}
s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
c := http.Client{}
parsedUrl, err := url.Parse(reqUrl)
if err != nil {
slog.Error("parse error", "err", err)
return vocab.Actor{}, err
}
req, _ := http.NewRequest("GET", reqUrl, nil)
req.Header.Set("Accept", "application/ld+json")
req.Header.Set("Date", time.Now().Format(http.TimeFormat))
req.Header.Set("Host", parsedUrl.Host)
err = s.sign(apConfig.PrivateKey(), s.MainKeyUri(), nil, req)
if err != nil {
slog.Error("Signing error", "err", err)
return vocab.Actor{}, err
}
resp, err := c.Do(req)
if err != nil {
slog.Error("failed to retrieve sender actor", "err", err, "url", reqUrl)
return vocab.Actor{}, err
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return vocab.Actor{}, err
}
item, err := vocab.UnmarshalJSON(data)
if err != nil {
return vocab.Actor{}, err
}
var actor vocab.Actor
err = vocab.OnActor(item, func(o *vocab.Actor) error {
actor = *o
return nil
})
return actor, err
}
func (s *ActivityPubService) VerifySignature(r *http.Request, sender string) error {
siteConfig := model.SiteConfig{}
apConfig := ActivityPubConfig{}
s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
slog.Info("verifying for", "sender", sender, "retriever", s.ActorUrl())
actor, err := s.GetActor(sender)
// actor does not have a pub key -> don't verify
if actor.PublicKey.PublicKeyPem == "" {
return nil
}
if err != nil {
slog.Error("unable to retrieve actor for sig verification", "sender", sender)
return err
}
block, _ := pem.Decode([]byte(actor.PublicKey.PublicKeyPem))
pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
slog.Error("unable to decode pub key pem", "pubKeyPem", actor.PublicKey.PublicKeyPem)
return err
}
slog.Info("retrieved pub key of sender", "actor", actor, "pubKey", pubKey)
verifier, err := httpsig.NewVerifier(r)
if err != nil {
slog.Error("invalid signature", "err", err)
return err
}
return verifier.Verify(pubKey, httpsig.RSA_SHA256)
}
func (s *ActivityPubService) Accept(act *vocab.Activity) error {
actor, err := s.GetActor(act.Actor.GetID().String())
if err != nil {
return err
}
accept := vocab.AcceptNew(vocab.IRI(s.AcccepId()), act)
data, err := jsonld.WithContext(
jsonld.IRI(vocab.ActivityBaseURI),
).Marshal(accept)
if err != nil {
slog.Error("marshalling error", "err", err)
return err
}
return s.sendObject(actor, data)
}
func (s *ActivityPubService) AddLike(sender string, liked string, likeId string) error {
entry, err := s.entryService.FindByUrl(liked)
if err != nil {
return err
}
actor, err := s.GetActor(sender)
if err != nil {
return err
}
var like *interactions.Like
interaction, err := s.interactionRepository.FindById(likeId)
if err != nil {
interaction = &interactions.Like{}
}
like, ok := interaction.(*interactions.Like)
if !ok {
return errors.New("existing interaction with same id is not a like")
}
existing := like.ID() != ""
likeMeta := interactions.LikeMetaData{
SenderUrl: sender,
SenderName: actor.Name.String(),
}
like.SetID(likeId)
like.SetMetaData(&likeMeta)
like.SetEntryID(entry.ID())
like.SetCreatedAt(time.Now())
if !existing {
return s.interactionRepository.Create(like)
} else {
return s.interactionRepository.Update(like)
}
}
func (s *ActivityPubService) RemoveLike(id string) error {
interaction, err := s.interactionRepository.FindById(id)
if err != nil {
interaction = &interactions.Like{}
}
return s.interactionRepository.Delete(interaction)
}
func (s *ActivityPubService) AddRepost(sender string, reposted string, respostId string) error {
entry, err := s.entryService.FindByUrl(reposted)
if err != nil {
return err
}
actor, err := s.GetActor(sender)
if err != nil {
return err
}
var repost *interactions.Repost
interaction, err := s.interactionRepository.FindById(respostId)
if err != nil {
interaction = &interactions.Repost{}
}
repost, ok := interaction.(*interactions.Repost)
if !ok {
return errors.New("existing interaction with same id is not a like")
}
existing := repost.ID() != ""
repostMeta := interactions.RepostMetaData{
SenderUrl: sender,
SenderName: actor.Name.String(),
}
repost.SetID(respostId)
repost.SetMetaData(&repostMeta)
repost.SetEntryID(entry.ID())
repost.SetCreatedAt(time.Now())
if !existing {
return s.interactionRepository.Create(repost)
} else {
return s.interactionRepository.Update(repost)
}
}
func (s *ActivityPubService) RemoveRepost(id string) error {
interaction, err := s.interactionRepository.FindById(id)
if err != nil {
interaction = &interactions.Repost{}
}
return s.interactionRepository.Delete(interaction)
}
func (s *ActivityPubService) sendObject(to vocab.Actor, data []byte) error {
siteConfig := model.SiteConfig{}
apConfig := ActivityPubConfig{}
s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
if to.Inbox == nil {
slog.Error("actor has no inbox", "actor", to)
return errors.New("actor has no inbox")
}
actorUrl, err := url.Parse(to.Inbox.GetID().String())
if err != nil {
slog.Error("parse error", "err", err)
return err
}
c := http.Client{}
req, _ := http.NewRequest("POST", to.Inbox.GetID().String(), bytes.NewReader(data))
req.Header.Set("Accept", "application/ld+json")
req.Header.Set("Date", time.Now().Format(http.TimeFormat))
req.Header.Set("Host", actorUrl.Host)
err = s.sign(apConfig.PrivateKey(), s.MainKeyUri(), data, req)
if err != nil {
slog.Error("Signing error", "err", err)
return err
}
resp, err := c.Do(req)
if err != nil {
slog.Error("Sending error", "url", req.URL, "err", err)
return err
}
slog.Info("Request", "host", resp.Request.Header)
if resp.StatusCode > 299 {
body, _ := io.ReadAll(resp.Body)
slog.Error("Error sending Note", "method", resp.Request.Method, "url", resp.Request.URL, "status", resp.Status, "body", string(body))
return err
}
body, _ := io.ReadAll(resp.Body)
slog.Info("Sent Body", "body", string(data))
slog.Info("Retrieved", "status", resp.Status, "body", string(body))
return nil
}
/*
* Notifiers
*/
func (svc *ActivityPubService) NotifyEntryCreated(entry model.Entry) {
slog.Info("Processing Entry Create for ActivityPub")
followers, err := svc.AllFollowers()
if err != nil {
slog.Error("Cannot retrieve followers")
}
object, err := svc.entryToObject(entry)
if err != nil {
slog.Error("Cannot convert object", "err", err)
}
create := vocab.CreateNew(object.ID, object)
create.Actor = object.AttributedTo
create.To = object.To
create.Published = object.Published
data, err := jsonld.WithContext(
jsonld.IRI(vocab.ActivityBaseURI),
jsonld.Context{
jsonld.ContextElement{
Term: "toot",
IRI: jsonld.IRI("http://joinmastodon.org/ns#"),
},
},
).Marshal(create)
if err != nil {
slog.Error("marshalling error", "err", err)
}
for _, follower := range followers {
actor, err := svc.GetActor(follower)
if err != nil {
slog.Error("Unable to retrieve follower actor", "err", err)
}
svc.sendObject(actor, data)
}
}
func (svc *ActivityPubService) NotifyEntryUpdated(entry model.Entry) {
slog.Info("Processing Entry Create for ActivityPub")
followers, err := svc.AllFollowers()
if err != nil {
slog.Error("Cannot retrieve followers")
}
object, err := svc.entryToObject(entry)
if err != nil {
slog.Error("Cannot convert object", "err", err)
}
update := vocab.UpdateNew(object.ID, object)
update.Actor = object.AttributedTo
update.To = object.To
update.Published = object.Published
data, err := jsonld.WithContext(
jsonld.IRI(vocab.ActivityBaseURI),
jsonld.Context{
jsonld.ContextElement{
Term: "toot",
IRI: jsonld.IRI("http://joinmastodon.org/ns#"),
},
},
).Marshal(update)
if err != nil {
slog.Error("marshalling error", "err", err)
}
for _, follower := range followers {
actor, err := svc.GetActor(follower)
if err != nil {
slog.Error("Unable to retrieve follower actor", "err", err)
}
svc.sendObject(actor, data)
}
}
func (svc *ActivityPubService) NotifyEntryDeleted(entry model.Entry) {
obj, err := svc.entryToObject(entry)
if err != nil {
slog.Error("error converting to object", "err", err)
return
}
followers, err := svc.AllFollowers()
if err != nil {
slog.Error("Cannot retrieve followers")
}
delete := vocab.DeleteNew(obj.ID, obj)
delete.Actor = obj.AttributedTo
delete.To = obj.To
delete.Published = time.Now()
data, err := jsonld.WithContext(
jsonld.IRI(vocab.ActivityBaseURI),
).Marshal(delete)
if err != nil {
slog.Error("marshalling error", "err", err)
}
for _, follower := range followers {
actor, err := svc.GetActor(follower)
if err != nil {
slog.Error("Unable to retrieve follower actor", "err", err)
}
svc.sendObject(actor, data)
}
}
func (svc *ActivityPubService) entryToObject(entry model.Entry) (vocab.Object, error) {
// limit to notes for now
if noteEntry, ok := entry.(*entrytypes.Note); ok {
return svc.noteToObject(noteEntry), nil
}
if imageEntry, ok := entry.(*entrytypes.Image); ok {
return svc.imageToObject(imageEntry), nil
}
if articleEntry, ok := entry.(*entrytypes.Article); ok {
return svc.articleToObject(articleEntry), nil
}
if recipeEntry, ok := entry.(*entrytypes.Recipe); ok {
return svc.recipeToObject(recipeEntry), nil
}
slog.Warn("entry type not yet supported for activity pub")
return vocab.Object{}, errors.New("entry type not supported")
}
func (svc *ActivityPubService) noteToObject(noteEntry *entrytypes.Note) vocab.Object {
siteCfg, _ := svc.siteConfigServcie.GetSiteConfig()
content := noteEntry.Content()
r := regexp.MustCompile("#[a-z0-9_]+")
matches := r.FindAllString(string(content), -1)
tags := vocab.ItemCollection{}
for _, hashtag := range matches {
tags.Append(vocab.Object{
ID: vocab.ID(svc.HashtagId(hashtag)),
Name: vocab.NaturalLanguageValues{{Value: vocab.Content(hashtag)}},
})
}
note := vocab.Note{
ID: vocab.ID(noteEntry.FullUrl(siteCfg)),
Type: "Note",
To: vocab.ItemCollection{
vocab.PublicNS,
vocab.IRI(svc.FollowersUrl()),
},
Published: *noteEntry.PublishedAt(),
AttributedTo: vocab.ID(svc.ActorUrl()),
Content: vocab.NaturalLanguageValues{
{Value: vocab.Content(content)},
},
Tag: tags,
}
return note
}
func (svc *ActivityPubService) imageToObject(imageEntry *entrytypes.Image) vocab.Object {
siteCfg, _ := svc.siteConfigServcie.GetSiteConfig()
content := imageEntry.Content()
imgPath := imageEntry.ImageUrl()
fullImageUrl, _ := url.JoinPath(siteCfg.FullUrl, imgPath)
binaryFile, err := svc.binService.FindById(imageEntry.MetaData().(*entrytypes.ImageMetaData).ImageId)
if err != nil {
slog.Error("cannot get image file")
}
attachments := vocab.ItemCollection{}
attachments = append(attachments, vocab.Document{
Type: vocab.DocumentType,
MediaType: vocab.MimeType(binaryFile.Mime()),
URL: vocab.ID(fullImageUrl),
Name: vocab.NaturalLanguageValues{
{Value: vocab.Content(content)},
},
})
image := vocab.Image{
ID: vocab.ID(imageEntry.FullUrl(siteCfg)),
Type: "Image",
To: vocab.ItemCollection{
vocab.PublicNS,
vocab.IRI(svc.FollowersUrl()),
},
Published: *imageEntry.PublishedAt(),
AttributedTo: vocab.ID(svc.ActorUrl()),
Name: vocab.NaturalLanguageValues{
{Value: vocab.Content(imageEntry.Title())},
},
Content: vocab.NaturalLanguageValues{
{Value: vocab.Content(imageEntry.Title() + "<br><br>" + string(content))},
},
Attachment: attachments,
// Tag: tags,
}
return image
}
func (svc *ActivityPubService) articleToObject(articleEntry *entrytypes.Article) vocab.Object {
siteCfg, _ := svc.siteConfigServcie.GetSiteConfig()
content := articleEntry.Content()
image := vocab.Article{
ID: vocab.ID(articleEntry.FullUrl(siteCfg)),
Type: "Article",
To: vocab.ItemCollection{
vocab.PublicNS,
vocab.IRI(svc.FollowersUrl()),
},
Published: *articleEntry.PublishedAt(),
AttributedTo: vocab.ID(svc.ActorUrl()),
Name: vocab.NaturalLanguageValues{
{Value: vocab.Content(articleEntry.Title())},
},
Content: vocab.NaturalLanguageValues{
{Value: vocab.Content(string(content))},
},
}
return image
}
func (svc *ActivityPubService) recipeToObject(recipeEntry *entrytypes.Recipe) vocab.Object {
siteCfg, _ := svc.siteConfigServcie.GetSiteConfig()
content := recipeEntry.Content()
image := vocab.Article{
ID: vocab.ID(recipeEntry.FullUrl(siteCfg)),
Type: "Article",
To: vocab.ItemCollection{
vocab.PublicNS,
vocab.IRI(svc.FollowersUrl()),
},
Published: *recipeEntry.PublishedAt(),
AttributedTo: vocab.ID(svc.ActorUrl()),
Name: vocab.NaturalLanguageValues{
{Value: vocab.Content(recipeEntry.Title())},
},
Content: vocab.NaturalLanguageValues{
{Value: vocab.Content(string(content))},
},
}
return image
}

View File

@ -1,7 +1,6 @@
package app
import (
"errors"
"fmt"
"owl-blogs/app/repository"
"owl-blogs/domain/model"
@ -10,20 +9,17 @@ import (
)
type EntryService struct {
EntryRepository repository.EntryRepository
siteConfigServcie *SiteConfigService
Bus *EventBus
EntryRepository repository.EntryRepository
Bus *EventBus
}
func NewEntryService(
entryRepository repository.EntryRepository,
siteConfigServcie *SiteConfigService,
bus *EventBus,
) *EntryService {
return &EntryService{
EntryRepository: entryRepository,
siteConfigServcie: siteConfigServcie,
Bus: bus,
EntryRepository: entryRepository,
Bus: bus,
}
}
@ -48,13 +44,7 @@ func (s *EntryService) Create(entry model.Entry) error {
if err != nil {
return err
}
// only notify if the publishing date is set
// otherwise this is a draft.
// listeners might publish the entry to other services/platforms
// this should only happen for publshed content
if entry.PublishedAt() != nil && !entry.PublishedAt().IsZero() {
s.Bus.NotifyCreated(entry)
}
s.Bus.NotifyCreated(entry)
return nil
}
@ -63,13 +53,7 @@ func (s *EntryService) Update(entry model.Entry) error {
if err != nil {
return err
}
// only notify if the publishing date is set
// otherwise this is a draft.
// listeners might publish the entry to other services/platforms
// this should only happen for publshed content
if entry.PublishedAt() != nil && !entry.PublishedAt().IsZero() {
s.Bus.NotifyUpdated(entry)
}
s.Bus.NotifyUpdated(entry)
return nil
}
@ -78,9 +62,6 @@ func (s *EntryService) Delete(entry model.Entry) error {
if err != nil {
return err
}
// deletes should always be notfied
// a published entry might be converted to a draft before deletion
// omitting the deletion in this case would prevent deletion on other platforms
s.Bus.NotifyDeleted(entry)
return nil
}
@ -89,19 +70,6 @@ func (s *EntryService) FindById(id string) (model.Entry, error) {
return s.EntryRepository.FindById(id)
}
func (s *EntryService) FindByUrl(url string) (model.Entry, error) {
cfg, _ := s.siteConfigServcie.GetSiteConfig()
if !strings.HasPrefix(url, cfg.FullUrl) {
return nil, errors.New("url does not belong to blog")
}
if strings.HasSuffix(url, "/") {
url = url[:len(url)-1]
}
parts := strings.Split(url, "/")
id := parts[len(parts)-1]
return s.FindById(id)
}
func (s *EntryService) filterEntries(entries []model.Entry, published bool, drafts bool) []model.Entry {
filteredEntries := make([]model.Entry, 0)
for _, entry := range entries {

View File

@ -14,9 +14,7 @@ func setupService() *app.EntryService {
register := app.NewEntryTypeRegistry()
register.Register(&test.MockEntry{})
repo := infra.NewEntryRepository(db, register)
cfgRepo := infra.NewConfigRepo(db)
cfgService := app.NewSiteConfigService(cfgRepo)
service := app.NewEntryService(repo, cfgService, app.NewEventBus())
service := app.NewEntryService(repo, app.NewEventBus())
return service
}

View File

@ -51,9 +51,3 @@ type InteractionRepository interface {
// ListAllInteractions lists all interactions, sorted by creation date (descending)
ListAllInteractions() ([]model.Interaction, error)
}
type FollowerRepository interface {
Add(follower string) error
Remove(follower string) error
All() ([]string, error)
}

View File

@ -25,6 +25,7 @@ func (svc *SiteConfigService) defaultConfig() model.SiteConfig {
return model.SiteConfig{
Title: "My Owl-Blog",
SubTitle: "A freshly created blog",
HeaderColor: "#efc48c",
PrimaryColor: "#d37f12",
AuthorName: "",
Me: []model.MeLinks{},

View File

@ -92,6 +92,7 @@ var importCmd = &cobra.Command{
}
v2Config.Title = v1Config.Title
v2Config.SubTitle = v1Config.SubTitle
v2Config.HeaderColor = v1Config.HeaderColor
v2Config.AuthorName = v1Config.AuthorName
v2Config.Me = mes
v2Config.Lists = lists

View File

@ -41,8 +41,6 @@ func App(db infra.Database) *web.WebApp {
interactionRegister := app.NewInteractionTypeRegistry()
interactionRegister.Register(&interactions.Webmention{})
interactionRegister.Register(&interactions.Like{})
interactionRegister.Register(&interactions.Repost{})
configRegister := app.NewConfigRegister()
@ -52,7 +50,6 @@ func App(db infra.Database) *web.WebApp {
authorRepo := infra.NewDefaultAuthorRepo(db)
configRepo := infra.NewConfigRepo(db)
interactionRepo := infra.NewInteractionRepo(db, interactionRegister)
followersRepo := infra.NewFollowerRepository(db)
// Create External Services
httpClient := &infra.OwlHttpClient{}
@ -62,17 +59,12 @@ func App(db infra.Database) *web.WebApp {
// Create Services
siteConfigService := app.NewSiteConfigService(configRepo)
entryService := app.NewEntryService(entryRepo, siteConfigService, eventBus)
entryService := app.NewEntryService(entryRepo, eventBus)
binaryService := app.NewBinaryFileService(binRepo)
authorService := app.NewAuthorService(authorRepo, siteConfigService)
webmentionService := app.NewWebmentionService(
siteConfigService, interactionRepo, entryRepo, httpClient, eventBus,
)
apService := app.NewActivityPubService(
followersRepo, configRepo, interactionRepo,
entryService, siteConfigService, binaryService,
eventBus,
)
// setup render functions
render.SiteConfigService = siteConfigService
@ -88,7 +80,6 @@ func App(db infra.Database) *web.WebApp {
entryService, entryRegister, binaryService,
authorService, configRepo, configRegister,
siteConfigService, webmentionService, interactionRepo,
apService,
)
}

View File

@ -3,8 +3,7 @@ package config
import "os"
const (
SITE_CONFIG = "site_config"
ACT_PUB_CONF_NAME = "activity_pub"
SITE_CONFIG = "site_config"
)
type Config interface {

View File

@ -1,7 +1,6 @@
package model
import (
"net/url"
"time"
)
@ -22,8 +21,6 @@ type Entry interface {
SetPublishedAt(publishedAt *time.Time)
SetMetaData(metaData EntryMetaData)
SetAuthorId(authorId string)
FullUrl(cfg SiteConfig) string
}
type EntryMetaData interface {
@ -63,8 +60,3 @@ func (e *EntryBase) AuthorId() string {
func (e *EntryBase) SetAuthorId(authorId string) {
e.authorId = authorId
}
func (e *EntryBase) FullUrl(cfg SiteConfig) string {
u, _ := url.JoinPath(cfg.FullUrl, "/posts/", e.ID(), "/")
return u
}

View File

@ -1,34 +0,0 @@
package model_test
import (
"owl-blogs/domain/model"
"testing"
"github.com/stretchr/testify/require"
)
func TestEntryFullUrl(t *testing.T) {
type testCase struct {
Id string
Url string
Want string
}
testCases := []testCase{
{Id: "foobar", Url: "https://example.com", Want: "https://example.com/posts/foobar/"},
{Id: "foobar", Url: "https://example.com/", Want: "https://example.com/posts/foobar/"},
{Id: "foobar", Url: "http://example.com", Want: "http://example.com/posts/foobar/"},
{Id: "foobar", Url: "http://example.com/", Want: "http://example.com/posts/foobar/"},
{Id: "bi-bar-buz", Url: "https://example.com", Want: "https://example.com/posts/bi-bar-buz/"},
{Id: "foobar", Url: "https://example.com/lol/", Want: "https://example.com/lol/posts/foobar/"},
}
for _, test := range testCases {
e := model.EntryBase{}
e.SetID(test.Id)
cfg := model.SiteConfig{FullUrl: test.Url}
require.Equal(t, e.FullUrl(cfg), test.Want)
}
}

View File

@ -22,6 +22,7 @@ type MenuItem struct {
type SiteConfig struct {
Title string
SubTitle string
HeaderColor string
PrimaryColor string
AuthorName string
Me []MeLinks

View File

@ -1,49 +0,0 @@
import pytest
from requests import Session
from urllib.parse import urljoin
from tests.fixtures import ACCT_NAME
class LiveServerSession(Session):
def __init__(self, base_url=None):
super().__init__()
self.base_url = base_url
def request(self, method, url, *args, **kwargs):
joined_url = urljoin(self.base_url, url)
return super().request(method, joined_url, *args, **kwargs)
@pytest.fixture
def client():
return LiveServerSession("http://localhost:3000")
@pytest.fixture
def actor_url(client):
resp = client.get(f"/.well-known/webfinger?resource={ACCT_NAME}")
data = resp.json()
self_link = [x for x in data["links"] if x["rel"] == "self"][0]
return self_link["href"]
@pytest.fixture
def actor(client, actor_url):
resp = client.get(actor_url, headers={"Content-Type": "application/activity+json"})
assert resp.status_code == 200
return resp.json()
@pytest.fixture
def inbox_url(actor):
return actor["inbox"]
@pytest.fixture
def outbox_url(actor):
return actor["outbox"]
@pytest.fixture
def followers_url(actor):
return actor["followers"]

View File

@ -1,12 +0,0 @@
services:
web:
build:
context: ../
dockerfile: Dockerfile
command: web
ports:
- "3000:3000"
mock_masto:
build: mock_masto
ports:
- 8000:8000

View File

@ -1,24 +0,0 @@
services:
web:
build:
context: ../
dockerfile: Dockerfile.test
volumes:
- ../app:/go/owl/app
- ../assets:/go/owl/assets
- ../cmd:/go/owl/cmd
- ../config:/go/owl/config
- ../domain:/go/owl/domain
- ../entry_types:/go/owl/entry_types
- ../importer:/go/owl/importer
- ../infra:/go/owl/infra
- ../interactions:/go/owl/interactions
- ../plugings:/go/owl/plugings
- ../render:/go/owl/render
- ../web:/go/owl/web
ports:
- "3000:3000"
mock_masto:
build: mock_masto
ports:
- 8000:8000

View File

@ -1,6 +0,0 @@
FROM python:3.11
COPY . .
RUN pip install -r requirements.txt
CMD [ "python", "main.py" ]

View File

@ -1,214 +0,0 @@
import json
from flask import Flask, request
app = Flask(__name__)
PRIV_KEY_PEM = """-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCni8P4bvkC/3Sx
NTrDw1qw0vWtJKZMsyJ3Mcs4+1apoVqOQhujUqGqFSiRT7Vmc7OEhB0vikdiTkCk
1PcoTd/rOa/0WbG8385JcLzaJfTIG+rrRtHwZ1TwxwBju43jpGEZjpbA0dqoXMkr
J1MyD7aPLoAiVe0ikw2czSZumv4ncemOtk0VG3b2TnIxo3CMKtUOWu8xT08MMIuo
3cZRnpI6Xr/ULzvjv8e3EjIpwRJqMPECtGsfdcjFmR0yFIrjrlmkZTiW31z/Dk7i
xRGD0ADy3WEQ3lA4l3mNZeyG4S0Wi4iYe9/wegESMZcakLoME7ks+KNS388Mdbcd
DKy9NmWvAgMBAAECggEABLQAA0hHhdWv6+Lc9xkpFuTvxTV4fuyvCf4u1eGlnstg
ZF/nW1/6w8XQ8WCgbJ4mKuZz1J14FYKxfoRaj8S9MA2Ff+wd+M77gRpAuDWajRzO
LQk8OW2yd7POXKkAzvln9F9eofkCFKR4zSpPGTenCJaQkuYrQEOKfUf7oofdRzQi
w9kmp3wAxM/EseHZpknYDCgDQV7MDQAaMD7kbynL2WfXPxebktwpRlKUwgtGrevj
gagQL8J/GX6wO3ymw9sln4BhlI2+3LuiMXQdQc1tamkXFCguCuOZCu/2VRdCHmiS
nnpu+FMspBHbvxO+RXo3Cu/S6jjJgoQxD2WZTE0gqQKBgQDM6AQdqBYjISdkI9Gl
6ZLLjwZRJSYpopujtX7pun61l9kUwQevaR2Z39rMWxX62DD6arazi/ygIUBw6Kgp
s/qBEb29ec+0cESdC8aJYb3dGvDzh/8C05p7ozxj8JZQcxq5W5jql/BELlSsUONO
jfqQv8RGZNSkD9uy6TxOr4eWIwKBgQDRUuO/XRDLt8Mp10mTshxTznSQ3gAJYKeG
0WfEC3kPEukHBQb8huqFcQDiQ71oBWuEdOQWgT3aBS6L+nIMyZMT5u+BejQm7/E5
pMM+z0VRpfFSsIrCvU8yKam0aemQGlKQAfhTct1gCg+wKnYsSQMlNHKWEfDbw9I/
cns/IN+dBQKBgQC6/Of0oFVDTZgC3GUPAO3C8QwUtM/0or1hUdk1Nck3shCZzeVT
f5tRtmSWpHCUbwGTJBsCEjdBcda6srXzCJkLe8Moy6ZtxR34KqzM5fM7eMB1nJ9s
Vunc9gPAN+cUF1ZF3H7ZZjoOHjGK5m3oW8xSl41np9Acv5P/2rP8Ilaa/QKBgQDJ
YwISfitGk8mEW8hB/L4cMykapztJyl/i6Vz31EHoKr1fL4sFMZg4QfwjtCBqD6zd
hshajoU/WHTr30wS2WxTXX9YBoZeX8KpPsdJioiagRioAYm+yfuDu2m2VZ+MMIb2
Xa7YOk6Zs5RcXL3M5YHNLaSAlUoxZTjGKhJBLhN1MQKBgQCbo3ngBl7Qjjx4WJ93
2WEEKvSDCv69eecNQDuKWKEiFqBN23LheNrN8DXMWFTtE4miY106dzQ0dUMh418x
K98rXSX3VvY4w48AznvPMKVLqesFjcvwnBdvk/NqXod20CMSpOEVj6W/nGoTBQt2
0PuW3IUym9KvO0WX9E+1Qw8mbw==
-----END PRIVATE KEY-----"""
PUB_KEY_PEM = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp4vD+G75Av90sTU6w8Na
sNL1rSSmTLMidzHLOPtWqaFajkIbo1KhqhUokU+1ZnOzhIQdL4pHYk5ApNT3KE3f
6zmv9FmxvN/OSXC82iX0yBvq60bR8GdU8McAY7uN46RhGY6WwNHaqFzJKydTMg+2
jy6AIlXtIpMNnM0mbpr+J3HpjrZNFRt29k5yMaNwjCrVDlrvMU9PDDCLqN3GUZ6S
Ol6/1C8747/HtxIyKcESajDxArRrH3XIxZkdMhSK465ZpGU4lt9c/w5O4sURg9AA
8t1hEN5QOJd5jWXshuEtFouImHvf8HoBEjGXGpC6DBO5LPijUt/PDHW3HQysvTZl
rwIDAQAB
-----END PUBLIC KEY-----"""
INBOX = []
@app.route("/.well-known/webfinger")
def webfinger():
return json.dumps(
{
"subject": "acct:h4kor@mock_masto",
"aliases": [
"http://mock_masto:8000/@h4kor",
"http://mock_masto:8000/users/h4kor",
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "http://mock_masto:8000/@h4kor",
},
{
"rel": "self",
"type": "application/activity+json",
"href": "http://mock_masto:8000/users/h4kor",
},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",
"template": "http://mock_masto:8000/authorize_interaction?uri={uri}",
},
{
"rel": "http://webfinger.net/rel/avatar",
"type": "image/png",
"href": "http://assets.mock_masto/accounts/avatars/000/082/056/original/a4be9944e3b03229.png",
},
],
}
)
@app.route("/users/h4kor")
def actor():
print("request to actor")
return json.dumps(
{
"@context": [
"http://www.w3.org/ns/activitystreams",
"http://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"toot": "http://joinmastodon.org/ns#",
"featured": {"@id": "toot:featured", "@type": "@id"},
"featuredTags": {"@id": "toot:featuredTags", "@type": "@id"},
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
"movedTo": {"@id": "as:movedTo", "@type": "@id"},
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value",
"discoverable": "toot:discoverable",
"Device": "toot:Device",
"Ed25519Signature": "toot:Ed25519Signature",
"Ed25519Key": "toot:Ed25519Key",
"Curve25519Key": "toot:Curve25519Key",
"EncryptedMessage": "toot:EncryptedMessage",
"publicKeyBase64": "toot:publicKeyBase64",
"deviceId": "toot:deviceId",
"claim": {"@type": "@id", "@id": "toot:claim"},
"fingerprintKey": {"@type": "@id", "@id": "toot:fingerprintKey"},
"identityKey": {"@type": "@id", "@id": "toot:identityKey"},
"devices": {"@type": "@id", "@id": "toot:devices"},
"messageFranking": "toot:messageFranking",
"messageType": "toot:messageType",
"cipherText": "toot:cipherText",
"suspended": "toot:suspended",
"memorial": "toot:memorial",
"indexable": "toot:indexable",
"Hashtag": "as:Hashtag",
"focalPoint": {"@container": "@list", "@id": "toot:focalPoint"},
},
],
"id": "http://mock_masto:8000/users/h4kor",
"type": "Person",
"following": "http://mock_masto:8000/users/h4kor/following",
"followers": "http://mock_masto:8000/users/h4kor/followers",
"inbox": "http://mock_masto:8000/users/h4kor/inbox",
"outbox": "http://mock_masto:8000/users/h4kor/outbox",
"featured": "http://mock_masto:8000/users/h4kor/collections/featured",
"featuredTags": "http://mock_masto:8000/users/h4kor/collections/tags",
"preferredUsername": "h4kor",
"name": "Niko",
"summary": '<p>Teaching computers to do things with arguable efficiency.</p><p>he/him</p><p><a href="http://mock_masto:8000/tags/vegan" class="mention hashtag" rel="tag">#<span>vegan</span></a> <a href="http://mock_masto:8000/tags/cooking" class="mention hashtag" rel="tag">#<span>cooking</span></a> <a href="http://mock_masto:8000/tags/programming" class="mention hashtag" rel="tag">#<span>programming</span></a> <a href="http://mock_masto:8000/tags/politics" class="mention hashtag" rel="tag">#<span>politics</span></a> <a href="http://mock_masto:8000/tags/climate" class="mention hashtag" rel="tag">#<span>climate</span></a></p>',
"url": "http://mock_masto:8000/@h4kor",
"manuallyApprovesFollowers": False,
"discoverable": True,
"indexable": False,
"published": "2018-08-16T00:00:00Z",
"memorial": False,
"devices": "http://mock_masto:8000/users/h4kor/collections/devices",
"publicKey": {
"id": "http://mock_masto:8000/users/h4kor#main-key",
"owner": "http://mock_masto:8000/users/h4kor",
"publicKeyPem": PUB_KEY_PEM,
},
"tag": [
{
"type": "Hashtag",
"href": "http://mock_masto:8000/tags/politics",
"name": "#politics",
},
{
"type": "Hashtag",
"href": "http://mock_masto:8000/tags/climate",
"name": "#climate",
},
{
"type": "Hashtag",
"href": "http://mock_masto:8000/tags/vegan",
"name": "#vegan",
},
{
"type": "Hashtag",
"href": "http://mock_masto:8000/tags/programming",
"name": "#programming",
},
{
"type": "Hashtag",
"href": "http://mock_masto:8000/tags/cooking",
"name": "#cooking",
},
],
"attachment": [
{
"type": "PropertyValue",
"name": "Me",
"value": '<a href="http://rerere.org" target="_blank" rel="nofollow noopener noreferrer me" translate="no"><span class="invisible">http://</span><span class="">rerere.org</span><span class="invisible"></span></a>',
},
{
"type": "PropertyValue",
"name": "Blog",
"value": '<a href="http://blog.libove.org" target="_blank" rel="nofollow noopener noreferrer me" translate="no"><span class="invisible">http://</span><span class="">blog.libove.org</span><span class="invisible"></span></a>',
},
{"type": "PropertyValue", "name": "Location", "value": "Münster"},
{
"type": "PropertyValue",
"name": "Current Project",
"value": '<a href="http://git.libove.org/h4kor/owl-blogs" target="_blank" rel="nofollow noopener noreferrer me" translate="no"><span class="invisible">http://</span><span class="">git.libove.org/h4kor/owl-blogs</span><span class="invisible"></span></a>',
},
],
"endpoints": {"sharedInbox": "http://mock_masto:8000/inbox"},
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "http://assets.mock_masto/accounts/avatars/000/082/056/original/a4be9944e3b03229.png",
},
}
)
@app.route("/users/h4kor/inbox", methods=["POST"])
def inbox():
if request.method == "POST":
INBOX.append(json.loads(request.get_data()))
return ""
@app.route("/msgs")
def msgs():
return json.dumps(INBOX)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port="8000")

View File

@ -1 +0,0 @@
Flask==3.0.3

View File

@ -1,17 +0,0 @@
certifi==2024.2.2
cffi==1.16.0
charset-normalizer==3.3.2
cryptography==42.0.7
exceptiongroup==1.2.1
http-message-signatures==0.5.0
http_sfv==0.9.9
idna==3.7
iniconfig==2.0.0
packaging==24.0
pluggy==1.5.0
pycparser==2.22
pytest==8.2.0
requests==2.31.0
tomli==2.0.1
typing_extensions==4.11.0
urllib3==2.2.1

View File

@ -1,102 +0,0 @@
from contextlib import contextmanager
from datetime import datetime, timezone
import json
from time import sleep
from urllib.parse import urlparse
import requests, base64, hashlib
from urllib.parse import urlparse
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
ACCT_NAME = "acct:blog@localhost:3000"
PRIV_KEY_PEM = """-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCni8P4bvkC/3Sx
NTrDw1qw0vWtJKZMsyJ3Mcs4+1apoVqOQhujUqGqFSiRT7Vmc7OEhB0vikdiTkCk
1PcoTd/rOa/0WbG8385JcLzaJfTIG+rrRtHwZ1TwxwBju43jpGEZjpbA0dqoXMkr
J1MyD7aPLoAiVe0ikw2czSZumv4ncemOtk0VG3b2TnIxo3CMKtUOWu8xT08MMIuo
3cZRnpI6Xr/ULzvjv8e3EjIpwRJqMPECtGsfdcjFmR0yFIrjrlmkZTiW31z/Dk7i
xRGD0ADy3WEQ3lA4l3mNZeyG4S0Wi4iYe9/wegESMZcakLoME7ks+KNS388Mdbcd
DKy9NmWvAgMBAAECggEABLQAA0hHhdWv6+Lc9xkpFuTvxTV4fuyvCf4u1eGlnstg
ZF/nW1/6w8XQ8WCgbJ4mKuZz1J14FYKxfoRaj8S9MA2Ff+wd+M77gRpAuDWajRzO
LQk8OW2yd7POXKkAzvln9F9eofkCFKR4zSpPGTenCJaQkuYrQEOKfUf7oofdRzQi
w9kmp3wAxM/EseHZpknYDCgDQV7MDQAaMD7kbynL2WfXPxebktwpRlKUwgtGrevj
gagQL8J/GX6wO3ymw9sln4BhlI2+3LuiMXQdQc1tamkXFCguCuOZCu/2VRdCHmiS
nnpu+FMspBHbvxO+RXo3Cu/S6jjJgoQxD2WZTE0gqQKBgQDM6AQdqBYjISdkI9Gl
6ZLLjwZRJSYpopujtX7pun61l9kUwQevaR2Z39rMWxX62DD6arazi/ygIUBw6Kgp
s/qBEb29ec+0cESdC8aJYb3dGvDzh/8C05p7ozxj8JZQcxq5W5jql/BELlSsUONO
jfqQv8RGZNSkD9uy6TxOr4eWIwKBgQDRUuO/XRDLt8Mp10mTshxTznSQ3gAJYKeG
0WfEC3kPEukHBQb8huqFcQDiQ71oBWuEdOQWgT3aBS6L+nIMyZMT5u+BejQm7/E5
pMM+z0VRpfFSsIrCvU8yKam0aemQGlKQAfhTct1gCg+wKnYsSQMlNHKWEfDbw9I/
cns/IN+dBQKBgQC6/Of0oFVDTZgC3GUPAO3C8QwUtM/0or1hUdk1Nck3shCZzeVT
f5tRtmSWpHCUbwGTJBsCEjdBcda6srXzCJkLe8Moy6ZtxR34KqzM5fM7eMB1nJ9s
Vunc9gPAN+cUF1ZF3H7ZZjoOHjGK5m3oW8xSl41np9Acv5P/2rP8Ilaa/QKBgQDJ
YwISfitGk8mEW8hB/L4cMykapztJyl/i6Vz31EHoKr1fL4sFMZg4QfwjtCBqD6zd
hshajoU/WHTr30wS2WxTXX9YBoZeX8KpPsdJioiagRioAYm+yfuDu2m2VZ+MMIb2
Xa7YOk6Zs5RcXL3M5YHNLaSAlUoxZTjGKhJBLhN1MQKBgQCbo3ngBl7Qjjx4WJ93
2WEEKvSDCv69eecNQDuKWKEiFqBN23LheNrN8DXMWFTtE4miY106dzQ0dUMh418x
K98rXSX3VvY4w48AznvPMKVLqesFjcvwnBdvk/NqXod20CMSpOEVj6W/nGoTBQt2
0PuW3IUym9KvO0WX9E+1Qw8mbw==
-----END PRIVATE KEY-----"""
def ensure_follow(client, inbox_url, actor_url):
req = sign(
"POST",
inbox_url,
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://mock_masto:8000/d0b5768b-a15b-4ed6-bc84-84c7e2b57588",
"type": "Follow",
"actor": "http://mock_masto:8000/users/h4kor",
"object": actor_url,
},
)
resp = requests.Session().send(req)
assert resp.status_code == 200
def sign(method, url, data):
priv_key = load_pem_private_key(PRIV_KEY_PEM.encode(), None)
body = json.dumps(data).encode()
body_hash = hashlib.sha256(body).digest()
digest = "SHA-256=" + base64.b64encode(body_hash).decode()
date = datetime.now(tz=timezone.utc).strftime("%a, %d %b %Y %H:%M:%S GMT")
host = "localhost:3000"
target = urlparse(url).path
to_sign = f"""(request-target): {method.lower()} {target}
host: {host}
date: {date}""".encode()
sig = priv_key.sign(
to_sign,
padding.PKCS1v15(),
hashes.SHA256(),
)
sig_str = base64.b64encode(sig).decode()
request = requests.Request(method, url, data=body)
request = request.prepare()
request.headers["Content-Digest"] = digest
request.headers["Host"] = host
request.headers["Date"] = date
request.headers["Signature"] = (
f'keyId="http://mock_masto:8000/users/h4kor#main-key",headers="(request-target) host date",signature="{sig_str}"'
)
return request
@contextmanager
def msg_inc(n):
resp = requests.get("http://localhost:8000/msgs")
data = resp.json()
msgs = len(data)
yield
sleep(0.2)
resp = requests.get("http://localhost:8000/msgs")
data = resp.json()
assert msgs + n == len(
data
), f"prev: {msgs}, now: {len(data)}, expected: {msgs + n}"

View File

@ -1,88 +0,0 @@
from pprint import pprint
from time import sleep
import requests
from .fixtures import ensure_follow, msg_inc, sign
import pytest
def test_actor(client, actor_url):
resp = client.get(actor_url, headers={"Content-Type": "application/activity+json"})
assert resp.status_code == 200
data = resp.json()
assert "id" in data
assert "type" in data
assert "inbox" in data
assert "outbox" in data
assert "followers" in data
assert "preferredUsername" in data
assert "publicKey" in data
assert len(data["publicKey"])
pubKey = data["publicKey"]
assert "id" in pubKey
assert "owner" in pubKey
assert "publicKeyPem" in pubKey
assert pubKey["owner"] == data["id"]
assert pubKey["id"] != data["id"]
assert "-----BEGIN RSA PUBLIC KEY-----" in pubKey["publicKeyPem"]
def test_following(client, inbox_url, followers_url, actor_url):
with msg_inc(1):
req = sign(
"POST",
inbox_url,
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://mock_masto:8000/d0b5768b-a15b-4ed6-bc84-84c7e2b57588",
"type": "Follow",
"actor": "http://mock_masto:8000/users/h4kor",
"object": actor_url,
},
)
resp = requests.Session().send(req)
assert resp.status_code == 200
resp = client.get(
followers_url, headers={"Content-Type": "application/activity+json"}
)
assert resp.status_code == 200
data = resp.json()
pprint(data)
assert "items" in data
assert len(data["items"]) == 1
def test_unfollow(client, inbox_url, followers_url, actor_url):
ensure_follow(client, inbox_url, actor_url)
sleep(0.5)
with msg_inc(1):
req = sign(
"POST",
inbox_url,
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://mock_masto:8000/users/h4kor#follows/3632040/undo",
"type": "Undo",
"actor": "http://mock_masto:8000/users/h4kor",
"object": {
"id": "http://mock_masto:8000/d0b5768b-a15b-4ed6-bc84-84c7e2b57588",
"type": "Follow",
"actor": "http://mock_masto:8000/users/h4kor",
"object": actor_url,
},
},
)
resp = requests.Session().send(req)
assert resp.status_code == 200
resp = client.get(
followers_url, headers={"Content-Type": "application/activity+json"}
)
assert resp.status_code == 200
data = resp.json()
pprint(data)
assert "totalItems" in data
assert data["totalItems"] == 0

View File

@ -1,27 +0,0 @@
import pytest
from .fixtures import ACCT_NAME
@pytest.mark.parametrize(
["query", "status"],
[
["", 404],
["?foo=bar", 404],
["?resource=lol@bar.com", 404],
[f"?resource={ACCT_NAME}", 200],
],
)
def test_webfinger_status(client, query, status):
resp = client.get("/.well-known/webfinger" + query)
assert resp.status_code == status
def test_webfinger(client):
resp = client.get(f"/.well-known/webfinger?resource={ACCT_NAME}")
assert resp.status_code == 200
data = resp.json()
assert data["subject"] == ACCT_NAME
assert len(data["links"]) > 0
self_link = [x for x in data["links"] if x["rel"] == "self"][0]
assert self_link["type"] == "application/activity+json"
assert "href" in self_link

1
go.mod
View File

@ -26,7 +26,6 @@ require (
github.com/chromedp/sysutil v1.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-ap/errors v0.0.0-20240304112515-6077fa9c17b0 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect

8
go.sum
View File

@ -22,8 +22,6 @@ github.com/go-ap/errors v0.0.0-20240304112515-6077fa9c17b0 h1:H9MGShwybHLSln6K8R
github.com/go-ap/errors v0.0.0-20240304112515-6077fa9c17b0/go.mod h1:5x8a6P/dhmMGFxWLcyYlyOuJ2lRNaHGhRv+yu8BaTSI=
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw=
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
@ -86,21 +84,15 @@ github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVS
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=

View File

@ -1,57 +0,0 @@
package infra
import (
"owl-blogs/app/repository"
"github.com/jmoiron/sqlx"
)
type sqlFollower struct {
Follwer string `db:"follower"`
}
type DefaultFollowerRepo struct {
db *sqlx.DB
}
func NewFollowerRepository(db Database) repository.FollowerRepository {
sqlxdb := db.Get()
// Create tables if not exists
sqlxdb.MustExec(`
CREATE TABLE IF NOT EXISTS followers (
follower TEXT PRIMARY KEY
);
`)
return &DefaultFollowerRepo{
db: sqlxdb,
}
}
// Add implements repository.FollowerRepository.
func (d *DefaultFollowerRepo) Add(follower string) error {
_, err := d.db.Exec("INSERT INTO followers (follower) VALUES (?) ON CONFLICT DO NOTHING", follower)
return err
}
// Remove implements repository.FollowerRepository.
func (d *DefaultFollowerRepo) Remove(follower string) error {
_, err := d.db.Exec("DELETE FROM followers WHERE follower = ?", follower)
return err
}
// All implements repository.FollowerRepository.
func (d *DefaultFollowerRepo) All() ([]string, error) {
var followers []sqlFollower
err := d.db.Select(&followers, "SELECT * FROM followers")
if err != nil {
return nil, err
}
result := []string{}
for _, follower := range followers {
result = append(result, follower.Follwer)
}
return result, nil
}

View File

@ -1,91 +0,0 @@
package infra_test
import (
"owl-blogs/app/repository"
"owl-blogs/infra"
"owl-blogs/test"
"testing"
"github.com/stretchr/testify/require"
)
func setupFollowerRepo() repository.FollowerRepository {
db := test.NewMockDb()
repo := infra.NewFollowerRepository(db)
return repo
}
func TestAddFollower(t *testing.T) {
repo := setupFollowerRepo()
err := repo.Add("foo@example.com")
require.NoError(t, err)
followers, err := repo.All()
require.NoError(t, err)
require.Len(t, followers, 1)
require.Equal(t, followers[0], "foo@example.com")
}
func TestDoubleAddFollower(t *testing.T) {
repo := setupFollowerRepo()
err := repo.Add("foo@example.com")
require.NoError(t, err)
err = repo.Add("foo@example.com")
require.NoError(t, err)
followers, err := repo.All()
require.NoError(t, err)
require.Len(t, followers, 1)
require.Equal(t, followers[0], "foo@example.com")
}
func TestMultipleAddFollower(t *testing.T) {
repo := setupFollowerRepo()
err := repo.Add("foo@example.com")
require.NoError(t, err)
err = repo.Add("bar@example.com")
require.NoError(t, err)
err = repo.Add("baz@example.com")
require.NoError(t, err)
followers, err := repo.All()
require.NoError(t, err)
require.Len(t, followers, 3)
}
func TestRemoveFollower(t *testing.T) {
repo := setupFollowerRepo()
err := repo.Add("foo@example.com")
require.NoError(t, err)
followers, err := repo.All()
require.NoError(t, err)
require.Len(t, followers, 1)
err = repo.Remove("foo@example.com")
require.NoError(t, err)
followers, err = repo.All()
require.NoError(t, err)
require.Len(t, followers, 0)
}
func TestRemoveNonExistingFollower(t *testing.T) {
repo := setupFollowerRepo()
err := repo.Remove("foo@example.com")
require.NoError(t, err)
followers, err := repo.All()
require.NoError(t, err)
require.Len(t, followers, 0)
}

View File

@ -1,33 +0,0 @@
package interactions
import (
"fmt"
"owl-blogs/domain/model"
"owl-blogs/render"
)
type Like struct {
model.InteractionBase
meta LikeMetaData
}
type LikeMetaData struct {
SenderUrl string
SenderName string
}
func (i *Like) Content() model.InteractionContent {
str, err := render.RenderTemplateToString("interaction/Like", i)
if err != nil {
fmt.Println(err)
}
return model.InteractionContent(str)
}
func (i *Like) MetaData() interface{} {
return &i.meta
}
func (i *Like) SetMetaData(metaData interface{}) {
i.meta = *metaData.(*LikeMetaData)
}

View File

@ -1,33 +0,0 @@
package interactions
import (
"fmt"
"owl-blogs/domain/model"
"owl-blogs/render"
)
type Repost struct {
model.InteractionBase
meta RepostMetaData
}
type RepostMetaData struct {
SenderUrl string
SenderName string
}
func (i *Repost) Content() model.InteractionContent {
str, err := render.RenderTemplateToString("interaction/Repost", i)
if err != nil {
fmt.Println(err)
}
return model.InteractionContent(str)
}
func (i *Repost) MetaData() interface{} {
return &i.meta
}
func (i *Repost) SetMetaData(metaData interface{}) {
i.meta = *metaData.(*RepostMetaData)
}

View File

@ -12,38 +12,57 @@
<link rel="webmention" href="/webmention/" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="/index.xml">
<link rel='stylesheet' href='/static/owl.css'>
<link rel='stylesheet' href='/static/style.css'>
<style>
:root {
--primary: {{.SiteConfig.PrimaryColor}};
--primary-hover: color-mix(in srgb,var(--primary),#000 20%);
--primary-focus: color-mix(in srgb,var(--primary),#fff 40%);
--primary-inverse: #FFF;
--background: {{.SiteConfig.HeaderColor}};
--background-dark: color-mix(in srgb,var(--background),#000 50%);
--background-light: color-mix(in srgb,var(--background),#fff 50%);
}
</style>
{{ .SiteConfig.HtmlHeadExtra }}
</head>
<body>
<header class="h-card">
{{ if .SiteConfig.AvatarUrl }}
<img class="u-photo u-logo avatar" src="{{ .SiteConfig.AvatarUrl }}" alt="{{ .SiteConfig.Title }}" />
{{ end }}
<header>
<div class="header h-card">
{{ if .SiteConfig.AvatarUrl }}
<div class="header-profile">
<img class="u-photo u-logo avatar" src="{{ .SiteConfig.AvatarUrl }}" alt="{{ .SiteConfig.Title }}" width="100" height="100" />
</div>
{{ end }}
<hgroup>
<h1><a class="p-name u-url" href="/">{{ .SiteConfig.Title }}</a></h1>
<p class="p-note">{{ .SiteConfig.SubTitle }}</p>
</hgroup>
<nav>
<ul>
{{ range $link := .SiteConfig.HeaderMenu }}
{{ if $link.List }}
<li><a href="/lists/{{ $link.List }}">{{ $link.Title }}</a></li>
{{ else if $link.Post }}
<li><a href="/posts/{{ $link.Post }}">{{ $link.Title }}</a></li>
{{ else }}
<li><a href="{{ $link.Url }}">{{ $link.Title }}</a></li>
{{ end }}
<div>
<h2><a class="p-name u-url" href="/">{{ .SiteConfig.Title }}</a></h2>
<div class="p-note">{{ .SiteConfig.SubTitle }}</div>
</div>
<ul style="list-style: none;padding:0;flex-shrink: 0;">
{{ range $me := .SiteConfig.Me }}
<li><a href="{{$me.Url}}" rel="me">{{$me.Name}}</a>
</li>
{{ end }}
</ul>
</nav>
</div>
<div>
<nav>
<ul>
{{ range $link := .SiteConfig.HeaderMenu }}
{{ if $link.List }}
<li><a href="/lists/{{ $link.List }}">{{ $link.Title }}</a></li>
{{ else if $link.Post }}
<li><a href="/posts/{{ $link.Post }}">{{ $link.Title }}</a></li>
{{ else }}
<li><a href="{{ $link.Url }}">{{ $link.Title }}</a></li>
{{ end }}
{{ end }}
</ul>
</nav>
</div>
</header>
<main>
{{template "main" .Data}}
@ -61,21 +80,16 @@
<li><a href="{{ $link.Url }}">{{ $link.Title }}</a></li>
{{ end }}
{{ end }}
{{ range $me := .SiteConfig.Me }}
<li><a href="{{$me.Url}}" rel="me">{{$me.Name}}</a>
</li>
{{ end }}
<li><a href="/admin/">Editor</a></li>
</ul>
</nav>
<div>
{{ .SiteConfig.FooterExtra}}
</div>
<div style="margin-top:var(--s2);">
powered by <i><a href="https://github.com/H4kor/owl-blogs" target="_blank">owl-blogs</a></i>
</a>
{{ .SiteConfig.FooterExtra}}
<small>
<nav>
<ul>
<li><a href="/admin/">Editor</a></li>
</ul>
</nav>
</small>
</footer>
</body>
</html>

View File

@ -1,3 +0,0 @@
Liked by <a href="{{.MetaData.SenderUrl}}">
{{.MetaData.SenderName}}
</a>

View File

@ -1,3 +0,0 @@
Reposted by <a href="{{.MetaData.SenderUrl}}">
{{.MetaData.SenderName}}
</a>

View File

@ -89,7 +89,7 @@
<div class="grid">
<div>
<div style="margin-bottom:1em;">
<a style="width:100%;" href="/editor/edit/{{.Entry.ID}}/" role="button" class="">Edit</a>
</div>
<div>

View File

@ -14,6 +14,9 @@
<label for="SubTitle">SubTitle</label>
<input type="text" name="SubTitle" id="SubTitle" value="{{.SubTitle}}"/>
<label for="HeaderColor">HeaderColor</label>
<input type="color" name="HeaderColor" id="HeaderColor" value="{{.HeaderColor}}"/>
<label for="PrimaryColor">PrimaryColor</label>
<input type="color" name="PrimaryColor" id="PrimaryColor" value="{{.PrimaryColor}}"/>

View File

@ -1,24 +1,44 @@
package web
import (
"errors"
"log/slog"
"net/http"
"net/url"
"owl-blogs/app"
"strings"
"owl-blogs/app/repository"
"owl-blogs/config"
"owl-blogs/domain/model"
"owl-blogs/render"
vocab "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/adaptor"
)
const ACT_PUB_CONF_NAME = "activity_pub"
type ActivityPubServer struct {
siteConfigService *app.SiteConfigService
apService *app.ActivityPubService
entryService *app.EntryService
configRepo repository.ConfigRepository
entryService *app.EntryService
}
type ActivityPubConfig struct {
PreferredUsername string
PublicKeyPem string
PrivateKeyPem string
}
// Form implements app.AppConfig.
func (cfg *ActivityPubConfig) Form(binSvc model.BinaryStorageInterface) string {
f, _ := render.RenderTemplateToString("forms/ActivityPubConfig", cfg)
return f
}
// ParseFormData implements app.AppConfig.
func (cfg *ActivityPubConfig) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
cfg.PreferredUsername = data.FormValue("PreferredUsername")
cfg.PublicKeyPem = data.FormValue("PublicKeyPem")
cfg.PrivateKeyPem = data.FormValue("PrivateKeyPem")
return nil
}
type WebfingerResponse struct {
@ -33,17 +53,18 @@ type WebfingerLink struct {
Href string `json:"href"`
}
func NewActivityPubServer(siteConfigService *app.SiteConfigService, entryService *app.EntryService, apService *app.ActivityPubService) *ActivityPubServer {
func NewActivityPubServer(configRepo repository.ConfigRepository, entryService *app.EntryService) *ActivityPubServer {
return &ActivityPubServer{
siteConfigService: siteConfigService,
entryService: entryService,
apService: apService,
configRepo: configRepo,
entryService: entryService,
}
}
func (s *ActivityPubServer) HandleWebfinger(ctx *fiber.Ctx) error {
siteConfig, _ := s.siteConfigService.GetSiteConfig()
apConfig, _ := s.apService.GetApConfig()
siteConfig := model.SiteConfig{}
apConfig := ActivityPubConfig{}
s.configRepo.Get(ACT_PUB_CONF_NAME, &apConfig)
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
domain, err := url.Parse(siteConfig.FullUrl)
if err != nil {
@ -51,9 +72,7 @@ func (s *ActivityPubServer) HandleWebfinger(ctx *fiber.Ctx) error {
}
subject := ctx.Query("resource", "")
blogSubject := "acct:" + apConfig.PreferredUsername + "@" + domain.Host
slog.Info("webfinger request", "for", subject, "required", blogSubject)
if subject != blogSubject {
if subject != "acct:"+apConfig.PreferredUsername+"@"+domain.Host {
return ctx.Status(404).JSON(nil)
}
@ -64,7 +83,7 @@ func (s *ActivityPubServer) HandleWebfinger(ctx *fiber.Ctx) error {
{
Rel: "self",
Type: "application/activity+json",
Href: s.apService.ActorUrl(),
Href: siteConfig.FullUrl + "/activitypub/actor",
},
},
}
@ -74,36 +93,26 @@ func (s *ActivityPubServer) HandleWebfinger(ctx *fiber.Ctx) error {
}
func (s *ActivityPubServer) Router(router fiber.Router) {
router.Get("/actor", s.HandleActor)
router.Get("/outbox", s.HandleOutbox)
router.Post("/inbox", s.HandleInbox)
router.Get("/followers", s.HandleFollowers)
}
func (s *ActivityPubServer) HandleActor(ctx *fiber.Ctx) error {
accepts := (strings.Contains(string(ctx.Request().Header.Peek("Accept")), "application/activity+json") ||
strings.Contains(string(ctx.Request().Header.Peek("Accept")), "application/ld+json"))
req_content := (strings.Contains(string(ctx.Request().Header.Peek("Content-Type")), "application/activity+json") ||
strings.Contains(string(ctx.Request().Header.Peek("Content-Type")), "application/ld+json"))
if !accepts && !req_content {
return ctx.Next()
}
apConfig, _ := s.apService.GetApConfig()
siteConfig := model.SiteConfig{}
apConfig := ActivityPubConfig{}
s.configRepo.Get(ACT_PUB_CONF_NAME, &apConfig)
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
actor := vocab.PersonNew(vocab.IRI(s.apService.ActorUrl()))
actor := vocab.PersonNew(vocab.IRI(siteConfig.FullUrl + "/activitypub/actor"))
actor.PreferredUsername = vocab.NaturalLanguageValues{{Value: vocab.Content(apConfig.PreferredUsername)}}
actor.Inbox = vocab.IRI(s.apService.InboxUrl())
actor.Outbox = vocab.IRI(s.apService.OutboxUrl())
actor.Followers = vocab.IRI(s.apService.FollowersUrl())
actor.Inbox = vocab.IRI(siteConfig.FullUrl + "/activitypub/inbox")
actor.Outbox = vocab.IRI(siteConfig.FullUrl + "/activitypub/outbox")
actor.Followers = vocab.IRI(siteConfig.FullUrl + "/activitypub/followers")
actor.PublicKey = vocab.PublicKey{
ID: vocab.IRI(s.apService.MainKeyUri()),
Owner: vocab.IRI(s.apService.ActorUrl()),
ID: vocab.ID(siteConfig.FullUrl + "/activitypub/actor#main-key"),
Owner: vocab.IRI(siteConfig.FullUrl + "/activitypub/actor"),
PublicKeyPem: apConfig.PublicKeyPem,
}
actor.Name = vocab.NaturalLanguageValues{{Value: vocab.Content(s.apService.ActorName())}}
actor.Icon = s.apService.ActorIcon()
actor.Summary = vocab.NaturalLanguageValues{{Value: vocab.Content(s.apService.ActorSummary())}}
data, err := jsonld.WithContext(
jsonld.IRI(vocab.ActivityBaseURI),
jsonld.IRI(vocab.SecurityContextURI),
@ -116,8 +125,10 @@ func (s *ActivityPubServer) HandleActor(ctx *fiber.Ctx) error {
}
func (s *ActivityPubServer) HandleOutbox(ctx *fiber.Ctx) error {
siteConfig, _ := s.siteConfigService.GetSiteConfig()
// apConfig, _ := s.apService.GetApConfig()
siteConfig := model.SiteConfig{}
apConfig := ActivityPubConfig{}
s.configRepo.Get(ACT_PUB_CONF_NAME, &apConfig)
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
entries, err := s.entryService.FindAllByType(nil, true, false)
if err != nil {
@ -136,7 +147,7 @@ func (s *ActivityPubServer) HandleOutbox(ctx *fiber.Ctx) error {
})
}
outbox := vocab.OrderedCollectionNew(vocab.IRI(s.apService.OutboxUrl()))
outbox := vocab.OrderedCollectionNew(vocab.IRI(siteConfig.FullUrl + "/activitypub/outbox"))
outbox.TotalItems = uint(len(items))
outbox.OrderedItems = items
@ -146,158 +157,5 @@ func (s *ActivityPubServer) HandleOutbox(ctx *fiber.Ctx) error {
}
ctx.Set("Content-Type", "application/activity+json")
return ctx.Send(data)
}
func (s *ActivityPubServer) processFollow(r *http.Request, act *vocab.Activity) error {
follower := act.Actor.GetID().String()
err := s.apService.VerifySignature(r, follower)
if err != nil {
slog.Error("wrong signature", "err", err)
return err
}
err = s.apService.AddFollower(follower)
if err != nil {
return err
}
go s.apService.Accept(act)
return nil
}
func (s *ActivityPubServer) processUndo(r *http.Request, act *vocab.Activity) error {
sender := act.Actor.GetID().String()
err := s.apService.VerifySignature(r, sender)
return vocab.OnObject(act.Object, func(o *vocab.Object) error {
if o.Type == vocab.FollowType {
if err != nil {
slog.Error("wrong signature", "err", err)
return err
}
err = s.apService.RemoveFollower(sender)
if err != nil {
return err
}
go s.apService.Accept(act)
return nil
}
if o.Type == vocab.LikeType {
return s.apService.RemoveLike(o.ID.String())
}
if o.Type == vocab.AnnounceType {
return s.apService.RemoveRepost(o.ID.String())
}
slog.Warn("unsupporeted object type for undo", "object", o)
return errors.New("unsupporeted object type")
})
}
func (s *ActivityPubServer) processLike(r *http.Request, act *vocab.Activity) error {
sender := act.Actor.GetID().String()
liked := act.Object.GetID().String()
err := s.apService.VerifySignature(r, sender)
if err != nil {
slog.Error("wrong signature", "err", err)
return err
}
err = s.apService.AddLike(sender, liked, act.ID.String())
if err != nil {
slog.Error("error saving like", "err", err)
return err
}
go s.apService.Accept(act)
return nil
}
func (s *ActivityPubServer) processAnnounce(r *http.Request, act *vocab.Activity) error {
sender := act.Actor.GetID().String()
liked := act.Object.GetID().String()
err := s.apService.VerifySignature(r, sender)
if err != nil {
slog.Error("wrong signature", "err", err)
return err
}
err = s.apService.AddRepost(sender, liked, act.ID.String())
if err != nil {
slog.Error("error saving like", "err", err)
return err
}
go s.apService.Accept(act)
return nil
}
func (s *ActivityPubServer) processDelete(r *http.Request, act *vocab.Activity) error {
return vocab.OnObject(act.Object, func(o *vocab.Object) error {
slog.Warn("Not processing delete", "action", act, "object", o)
return nil
})
}
func (s *ActivityPubServer) HandleInbox(ctx *fiber.Ctx) error {
body := ctx.Request().Body()
data, err := vocab.UnmarshalJSON(body)
if err != nil {
slog.Error("failed to parse request body", "body", body, "err", err)
return err
}
err = vocab.OnActivity(data, func(act *vocab.Activity) error {
slog.Info("activity retrieved", "activity", act, "type", act.Type)
r, err := adaptor.ConvertRequest(ctx, true)
if err != nil {
return err
}
if act.Type == vocab.FollowType {
return s.processFollow(r, act)
}
if act.Type == vocab.UndoType {
return s.processUndo(r, act)
}
if act.Type == vocab.DeleteType {
return s.processDelete(r, act)
}
if act.Type == vocab.LikeType {
return s.processLike(r, act)
}
if act.Type == vocab.AnnounceType {
return s.processAnnounce(r, act)
}
slog.Warn("Unsupported action", "body", body)
return errors.New("only follow and undo actions supported")
})
return err
}
func (s *ActivityPubServer) HandleFollowers(ctx *fiber.Ctx) error {
fs, err := s.apService.AllFollowers()
if err != nil {
return err
}
followers := vocab.Collection{}
for _, f := range fs {
followers.Append(vocab.IRI(f))
}
followers.TotalItems = uint(len(fs))
followers.ID = vocab.IRI(s.apService.FollowersUrl())
data, err := jsonld.WithContext(
jsonld.IRI(vocab.ActivityBaseURI),
).Marshal(followers)
if err != nil {
return err
}
ctx.Set("Content-Type", "application/activity+json")
return ctx.Send(data)
}

View File

@ -7,7 +7,6 @@ import (
"net/url"
"owl-blogs/app"
"owl-blogs/app/repository"
"owl-blogs/config"
"owl-blogs/web/middleware"
"github.com/gofiber/fiber/v2"
@ -36,13 +35,11 @@ func NewWebApp(
siteConfigService *app.SiteConfigService,
webmentionService *app.WebmentionService,
interactionRepo repository.InteractionRepository,
apService *app.ActivityPubService,
) *WebApp {
fiberApp := fiber.New(fiber.Config{
BodyLimit: 50 * 1024 * 1024, // 50MB in bytes
DisableStartupMessage: true,
app := fiber.New(fiber.Config{
BodyLimit: 50 * 1024 * 1024, // 50MB in bytes
})
fiberApp.Use(middleware.NewUserMiddleware(authorService).Handle)
app.Use(middleware.NewUserMiddleware(authorService).Handle)
indexHandler := NewIndexHandler(entryService, siteConfigService)
listHandler := NewListHandler(entryService, siteConfigService)
@ -54,15 +51,15 @@ func NewWebApp(
webmentionHandler := NewWebmentionHandler(webmentionService, configRepo)
// Login
fiberApp.Get("/auth/login", loginHandler.HandleGet)
fiberApp.Post("/auth/login", loginHandler.HandlePost)
app.Get("/auth/login", loginHandler.HandleGet)
app.Post("/auth/login", loginHandler.HandlePost)
// admin
adminHandler := NewAdminHandler(configRepo, configRegister, typeRegistry)
draftHandler := NewDraftHandler(entryService, siteConfigService)
binaryManageHandler := NewBinaryManageHandler(configRepo, binService)
adminInteractionHandler := NewAdminInteractionHandler(configRepo, interactionRepo)
admin := fiberApp.Group("/admin")
admin := app.Group("/admin")
admin.Use(middleware.NewAuthMiddleware(authorService).Handle)
admin.Get("/", adminHandler.Handle)
admin.Get("/drafts/", draftHandler.Handle)
@ -78,7 +75,7 @@ func NewWebApp(
adminApi.Post("/binaries", binaryManageHandler.HandleUploadApi)
// Editor
editor := fiberApp.Group("/editor")
editor := app.Group("/editor")
editor.Use(middleware.NewAuthMiddleware(authorService).Handle)
editor.Get("/new/:editor/", editorHandler.HandleGetNew)
editor.Post("/new/:editor/", editorHandler.HandlePostNew)
@ -88,7 +85,7 @@ func NewWebApp(
editor.Post("/unpublish/:id/", editorHandler.HandlePostUnpublish)
// SiteConfig
siteConfig := fiberApp.Group("/site-config")
siteConfig := app.Group("/site-config")
siteConfig.Use(middleware.NewAuthMiddleware(authorService).Handle)
siteConfigHandler := NewSiteConfigHandler(siteConfigService)
@ -110,40 +107,39 @@ func NewWebApp(
siteConfig.Post("/menus/create/", siteConfigMenusHandler.HandleCreate)
siteConfig.Post("/menus/delete/", siteConfigMenusHandler.HandleDelete)
activityPubServer := NewActivityPubServer(siteConfigService, entryService, apService)
configRegister.Register(config.ACT_PUB_CONF_NAME, &app.ActivityPubConfig{})
fiberApp.Use("/static", filesystem.New(filesystem.Config{
app.Use("/static", filesystem.New(filesystem.Config{
Root: http.FS(embedDirStatic),
PathPrefix: "static",
Browse: false,
}))
fiberApp.Get("/", activityPubServer.HandleActor, indexHandler.Handle)
fiberApp.Get("/lists/:list/", listHandler.Handle)
app.Get("/", indexHandler.Handle)
app.Get("/lists/:list/", listHandler.Handle)
// Media
fiberApp.Get("/media/+", mediaHandler.Handle)
app.Get("/media/+", mediaHandler.Handle)
// RSS
fiberApp.Get("/index.xml", rssHandler.Handle)
app.Get("/index.xml", rssHandler.Handle)
// Posts
fiberApp.Get("/posts/:post/", entryHandler.Handle)
app.Get("/posts/:post/", entryHandler.Handle)
// Webmention
fiberApp.Post("/webmention/", webmentionHandler.Handle)
app.Post("/webmention/", webmentionHandler.Handle)
// robots.txt
fiberApp.Get("/robots.txt", func(c *fiber.Ctx) error {
app.Get("/robots.txt", func(c *fiber.Ctx) error {
siteConfig, _ := siteConfigService.GetSiteConfig()
sitemapUrl, _ := url.JoinPath(siteConfig.FullUrl, "/sitemap.xml")
c.Set("Content-Type", "text/plain")
return c.SendString(fmt.Sprintf("User-agent: GPTBot\nDisallow: /\n\nUser-agent: *\nAllow: /\n\nSitemap: %s\n", sitemapUrl))
})
// sitemap.xml
fiberApp.Get("/sitemap.xml", NewSiteMapHandler(entryService, siteConfigService).Handle)
app.Get("/sitemap.xml", NewSiteMapHandler(entryService, siteConfigService).Handle)
// ActivityPub
fiberApp.Get("/.well-known/webfinger", activityPubServer.HandleWebfinger)
fiberApp.Route("/activitypub", activityPubServer.Router)
activityPubServer := NewActivityPubServer(configRepo, entryService)
configRegister.Register(ACT_PUB_CONF_NAME, &ActivityPubConfig{})
app.Get("/.well-known/webfinger", activityPubServer.HandleWebfinger)
app.Route("/activitypub", activityPubServer.Router)
return &WebApp{
FiberApp: fiberApp,
FiberApp: app,
EntryService: entryService,
Registry: typeRegistry,
BinaryService: binService,

View File

@ -38,6 +38,7 @@ func (h *SiteConfigHandler) HandlePost(c *fiber.Ctx) error {
siteConfig.Title = c.FormValue("Title")
siteConfig.SubTitle = c.FormValue("SubTitle")
siteConfig.HeaderColor = c.FormValue("HeaderColor")
siteConfig.PrimaryColor = c.FormValue("PrimaryColor")
siteConfig.AuthorName = c.FormValue("AuthorName")
siteConfig.AvatarUrl = c.FormValue("AvatarUrl")

View File

@ -1,486 +0,0 @@
:root {
/* font sizes (fs) */
/*
0 base
lX large
sX small
*/
--fs-scale: 1.125;
--fs0: 1rem;
--fsl1: calc(var(--fs0) * var(--fs-scale));
--fsl2: calc(var(--fsl1) * var(--fs-scale));
--fsl3: calc(var(--fsl2) * var(--fs-scale));
--fsl4: calc(var(--fsl3) * var(--fs-scale));
--fss1: calc(var(--fs0) / var(--fs-scale));
--fss2: calc(var(--fss1) / var(--fs-scale));
--fss3: calc(var(--fss2) / var(--fs-scale));
--fss4: calc(var(--fss3) / var(--fs-scale));
/* font weight */
--fw: 400;
--fwb: 700;
--fwl: 100;
/* font color */
/* fonts */
--font: Arial, Helvetica, sans-serif;
--font-h: 'Courier New', Courier, monospace;
--font-code: 'Courier New', Courier, monospace;
/* spacings */
--spacing-scale: 1.5;
--max-spacing: 4rem;
--s5: var(--max-spacing);
--s4: calc(var(--s5) / var(--spacing-scale));
--s3: calc(var(--s4) / var(--spacing-scale));
--s2: calc(var(--s3) / var(--spacing-scale));
--s1: calc(var(--s2) / var(--spacing-scale));
--s0: calc(var(--s1) / var(--spacing-scale));
/* content-width */
--cw: 620px;
/* colors */
--text: hsl(0, 0%, 17%);
--primary: hsl(200, 25%, 50%);
--primary-l1: color-mix(in srgb, var(--primary), #fff 20%);
--primary-l2: color-mix(in srgb, var(--primary), #fff 40%);
--primary-l3: color-mix(in srgb, var(--primary), #fff 60%);
--primary-l4: color-mix(in srgb, var(--primary), #fff 80%);
--primary-d1: color-mix(in srgb, var(--primary), #000 20%);
--primary-d2: color-mix(in srgb, var(--primary), #000 40%);
--primary-d3: color-mix(in srgb, var(--primary), #000 60%);
--primary-d4: color-mix(in srgb, var(--primary), #000 80%);
--secondary: color-mix(in hsl longer hue, var(--primary), var(--primary) 50%);
--secondary-l1: color-mix(in srgb, var(--secondary), #fff 20%);
--secondary-l2: color-mix(in srgb, var(--secondary), #fff 40%);
--secondary-l3: color-mix(in srgb, var(--secondary), #fff 60%);
--secondary-l4: color-mix(in srgb, var(--secondary), #fff 80%);
--secondary-d1: color-mix(in srgb, var(--secondary), #000 20%);
--secondary-d2: color-mix(in srgb, var(--secondary), #000 40%);
--secondary-d3: color-mix(in srgb, var(--secondary), #000 60%);
--secondary-d4: color-mix(in srgb, var(--secondary), #000 80%);
--text-primary: color-mix(in srgb, var(--primary), #fff 90%);
--text-secondary: color-mix(in srgb, var(--secondary), #fff 90%);
}
/* Styling of main page elements */
@media only screen and (max-width: 600px) {
main {
max-width: 100%;
padding-left: var(--s1);
padding-right: var(--s1);
}
}
@media screen and (min-width: 600px) {
main {
max-width: var(--cw);
}
}
* {
/* global properties*/
font-family: var(--font);
color: var(--text);
}
body {
display: flex;
flex-direction: column;
margin: 0;
min-height: 100vh;
}
html {
margin: 0;
min-height: 100vh;
}
main {
flex: 1;
margin: 0 auto;
}
:is(h1, h2, h3, h4, h5, h6) {
font-family: var(--font-h);
margin-top: var(--s4);
margin-bottom: var(--s0);
}
h1 {
font-size: var(--fsl4);
}
h2 {
font-size: var(--fsl3);
}
h3 {
font-size: var(--fsl2);
}
h4 {
font-size: var(--fsl1);
}
h5 {
font-size: var(--fs0);
font-weight: var(--fwb);
}
h6 {
font-size: var(--fsl4);
}
hr {
color: var(--primary);
border-style: dashed;
border-bottom: none;
border-width: 3px;
}
pre {
font-family: var(--font-code);
background-color: var(--primary-l4);
padding: var(--s0);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
main img {
max-width: 100%;
}
/* inline elements */
a {
text-decoration: none;
color: var(--primary);
}
a:hover {
text-decoration: underline;
color: var(--primary-d2);
}
abbr[title] {
border-bottom: 1px dashed;
text-decoration: none;
cursor: help;
}
kbd {
background-color: var(--primary-d3);
color: var(--text-primary);
padding: 0 var(--s0);
border-radius: var(--s0);
}
mark {
background-color: var(--secondary-l3);
}
code {
font-family: var(--font-code);
background-color: var(--primary-l4);
padding: 0 var(--s0);
border-radius: 2px;
}
/* lists */
ul,
ol {
padding-left: var(--s3);
}
li {
padding-bottom: var(--s0);
}
ul li {
list-style: square;
}
/* Quote */
blockquote {
margin-left: 0;
padding-left: var(--s4);
border-left: solid var(--primary) 3px;
}
blockquote>footer {
width: initial;
margin: initial;
background-color: initial;
color: initial;
padding-bottom: initial;
}
/* Table */
:where(table) {
border-collapse: collapse;
border-spacing: 0;
text-indent: 0;
}
table {
width: 100%;
}
th {
text-align: inherit;
padding: var(--s0);
border-bottom: solid var(--primary) 1px;
}
tr:nth-child(2n) {
background: var(--primary-l4);
}
td {
margin: 0;
padding: var(--s0);
border-bottom: solid var(--primary-l3) 1px;
}
/* buttons */
a[role='button'] {
display: inline-block;
box-sizing: border-box;
text-align: center;
}
a[role='button']:hover {
text-decoration: none;
}
button,
input[type='button'],
input[type='submit'],
a[role='button'] {
background-color: var(--primary);
color: var(--text-primary);
padding: var(--s1);
border: none;
border-radius: var(--s0);
font-size: var(--fs0);
}
button:hover,
input[type='button']:hover,
input[type='submit']:hover,
a[role='button']:hover {
background-color: var(--primary-d1);
}
button:disabled,
input[type='button']:disabled,
input[type='submit']:disabled,
a[role='button']:disabled {
background-color: var(--primary-l2);
}
input[type=checkbox]:checked,
input[type=radio]:checked,
input[type=range] {
accent-color: var(--primary);
}
/* forms */
label {
display: inline-block;
padding-bottom: var(--s0);
}
input,
textarea {
display: inline-block;
width: 100%;
vertical-align: middle;
margin-bottom: var(--s3);
padding: var(--s1);
box-sizing: border-box;
border: solid var(--primary-l2) 1px;
border-radius: var(--s0);
}
input[type='color'] {
padding: 0;
background-color: #fff;
}
label {
width: 100%;
}
label>input {
display: inherit;
width: initial;
margin-left: var(--s1);
margin-top: 0;
margin-bottom: 0;
}
label>input[type='color'] {
min-width: 170px;
}
/* button classes */
button.secondary,
input[type='button'].secondary,
input[type='submit'].secondary {
background-color: var(--secondary);
color: var(--text-secondary);
}
button.secondary:hover,
input[type='button'].secondary:hover,
input[type='submit'].secondary:hover {
background-color: var(--secondary-d1);
}
button.secondary:disabled,
input[type='button'].secondary:disabled,
input[type='submit'].secondary:disabled {
background-color: var(--secondary-l2);
}
/* Header specific styling */
header {
width: 100vw;
margin: 0;
margin-bottom: var(--s4);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
background-color: var(--primary);
color: var(--text-primary);
}
header img {
max-height: var(--s5);
margin-left: var(--s1);
}
header>hgroup {
flex: 1;
min-width: calc(var(--cw) / 2);
margin-right: var(--s5);
margin-left: var(--s1);
}
header>hgroup>h1 {
margin-top: var(--s0);
margin-bottom: var(--s0);
color: var(--text-primary);
}
header>hgroup>h1>a {
color: var(--text-primary);
}
header>hgroup>h1>a:hover {
color: var(--text-primary);
}
header>hgroup>p {
font-size: var(--fsl1);
margin-top: var(--s0);
margin-bottom: var(--s0);
font-family: var(--font);
color: var(--text-primary);
}
header>nav>ul {
display: flex;
list-style-type: none;
margin: 0;
padding-left: 0;
padding-top: var(--s1);
padding-bottom: var(--s1);
align-items: center;
flex-wrap: wrap;
}
header>nav>ul>li {
float: left;
margin-right: var(--s1);
padding: var(--s1);
color: var(--text-primary);
list-style-type: none;
}
header>nav>ul>li>a {
color: var(--text-primary);
}
header>nav>ul>li>a:hover {
color: var(--text-primary);
}
/* Footer specific styling */
footer {
width: 100vw;
margin: 0;
background-color: var(--primary);
color: var(--text-primary);
padding-bottom: var(--s5);
}
footer>div {
max-width: var(--cw);
margin: 0 auto;
color: var(--text-primary);
text-align: center;
}
footer>nav {
margin: 0 auto;
color: var(--text-primary);
}
footer>nav>ul {
display: flex;
justify-content: center;
list-style-type: none;
margin: 0;
padding-left: 0;
padding-top: var(--s1);
padding-bottom: var(--s1);
align-items: center;
flex-wrap: wrap;
}
footer>nav>ul>li {
float: left;
margin-right: var(--s1);
padding: var(--s1);
color: var(--text-primary);
list-style-type: none;
}
footer a {
color: var(--text-primary);
}
footer a:hover {
color: var(--text-primary);
}

View File

@ -1,3 +1,162 @@
body {
font-family: sans-serif;
max-width: 800px;
margin: 0 auto 0 auto;
}
header {
padding: 1rem;
background-color: var(--background);
/* background: linear-gradient(180deg, var(--background) 0%, rgba(255, 255, 255, 0) 100%); */
padding-bottom: 1rem !important;
margin-bottom: 2rem;
}
footer {
border-top: dashed 2px;
border-color: #ccc;
padding-bottom: 5rem;
}
label {
display: inline-block;
padding-bottom: 0.25rem;
}
input, textarea, a[role='button'] {
display: inline-block;
width: 100%;
vertical-align: middle;
margin-bottom: 0.75rem;
padding: 0.25rem;
box-sizing: border-box;
}
input[type='submit'], a[role='button'] {
background-color: var(--primary);
color: #fff;
border: none;
border-radius: 2px;
font-size: 1rem;
text-align: center;
height: 2rem;
}
input[type='submit']:hover, a[role='button']:hover {
background-color: var(--primary-hover);
color: #fff;
}
input[type='submit']:focus, a[role='button']:focus {
background-color: var(--primary-focus);
color: #fff;
}
input[type='submit'].secondary {
background-color: #5d6b89;
}
input[type='submit'].secondary:hover {
background-color: #48536b;
}
input[type='submit'].secondary:focus {
background-color: rgba(93, 107, 137, 0.25);
}
/* for checkboxes */
label > input {
display: inherit;
width: initial;
}
/* from pico.css */
mark {
padding: 0.125rem 0.25rem;
background-color: #fde7c0;
vertical-align: baseline;
}
main img {
max-width: 100%;
margin: auto;
text-align: center;
display: block;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
color: var(--primary-hover);
}
a:focus {
color: var(--primary-focus);
}
nav > ul {
list-style-type: none;
margin: 0;
padding-left: 0;
padding-top: 1rem;
padding-bottom: 1rem;
}
nav > ul > li {
float: left;
padding-right: 2rem;
}
hr {
margin: 3rem
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1.25rem;
}
h3 {
font-size: 1.125rem;
}
h4, h5, h6 {
font-size: 1.0rem;
}
pre {
background-color: #eee;
padding: 0.5rem;
white-space: pre-wrap;
}
table {
width: 100%;
}
th {
border-bottom: solid #888 1px;
}
tr:nth-child(2n) {
background: #f0f0f1;
}
.header {
display: flex;
flex-flow: row;
}
.header-profile {
padding-right: 0.5rem;
display: flex;
justify-content: center;
align-items: center;
}
.row {
display: flex;
@ -35,18 +194,18 @@ nav.row {
}
.action-tile {
border: 1px solid var(--primary-l1);
border: 1px solid var(--background-dark);
padding: 20px;
flex: 1 1 0px;
margin: 6px;
min-width: 20%;
text-align: center;
background: var(--primary-l4);
background: var(--background);
border-radius: 4px;
}
.action-tile:hover {
background: var(--primary-l3);
background: var(--background-light);
}
.danger {
@ -103,3 +262,26 @@ nav.row {
animation: border-pulsate-error 1s 2;
}
.avatar {
float: left;
margin-right: 1rem;
border-radius: 50%;
}
.photo-grid {
display: flex;
flex-wrap: wrap;
padding: 0 4px;
}
.photo-grid-item {
flex: 1 0 25%;
padding: 4px;
}
.photo-grid-item img {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
}