Compare commits

...

15 Commits

Author SHA1 Message Date
Niko Abeler da06541e11 spacing var 2024-05-18 22:05:44 +02:00
Niko Abeler 7b047a609f powered by 2024-05-18 22:04:23 +02:00
Niko Abeler a5f24427a1 cleanup + primary color usage 2024-05-18 21:59:32 +02:00
Niko Abeler ec13fffbe9 release flow 3 2024-05-18 21:11:03 +02:00
Niko Abeler 686cd72ec2 release flow 2 2024-05-18 21:06:42 +02:00
Niko Abeler 29e875d2e5 release flow 2024-05-18 20:49:07 +02:00
Niko Abeler f958fa36fd fix test 2024-05-18 20:31:50 +02:00
Niko Abeler 1b347bcdac CI setup 4 2024-05-18 20:20:00 +02:00
Niko Abeler c8b759a834 CI setup 3 2024-05-18 20:16:47 +02:00
Niko Abeler 943bc10eaf CI setup 2 2024-05-18 20:00:19 +02:00
Niko Abeler 4543e448ed update requirements 2024-05-18 19:57:15 +02:00
Niko Abeler e0d6f4f223 CI setup 1 2024-05-18 19:54:58 +02:00
Niko Abeler 49a602f68b
Create test.yml 2024-05-18 19:53:06 +02:00
Niko Abeler 596ab0047e CI prep + README 2024-05-18 19:49:55 +02:00
Niko Abeler 5c05f48be3 prevent publishing of drafts + support for recipes 2024-05-18 19:34:44 +02:00
16 changed files with 264 additions and 26 deletions

45
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,45 @@
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

38
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,38 @@
# 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

View File

@ -2,17 +2,9 @@
# Owl Blogs # Owl Blogs
A simple web server for blogs generated from Markdown files. Owl-blogs is a blogging software focused on simplicity with IndieWeb and Fediverse support.
**_This project is not yet stable. Expect frequent breaking changes! Only use this if you are willing to regularly adjust your project accordingly._** # Usage
## Build
```
CGO_ENABLED=1 go build -o owl ./cmd/owl
```
## Run ## Run
@ -34,4 +26,36 @@ To retrieve a list of all commands run:
``` ```
owl -h 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

@ -498,7 +498,41 @@ func (svc *ActivityPubService) NotifyEntryCreated(entry model.Entry) {
} }
func (svc *ActivityPubService) NotifyEntryUpdated(entry model.Entry) { 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) { func (svc *ActivityPubService) NotifyEntryDeleted(entry model.Entry) {
@ -543,6 +577,13 @@ func (svc *ActivityPubService) entryToObject(entry model.Entry) (vocab.Object, e
if imageEntry, ok := entry.(*entrytypes.Image); ok { if imageEntry, ok := entry.(*entrytypes.Image); ok {
return svc.imageToObject(imageEntry), nil 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") slog.Warn("entry type not yet supported for activity pub")
return vocab.Object{}, errors.New("entry type not supported") return vocab.Object{}, errors.New("entry type not supported")
} }
@ -595,11 +636,14 @@ func (svc *ActivityPubService) imageToObject(imageEntry *entrytypes.Image) vocab
Type: vocab.DocumentType, Type: vocab.DocumentType,
MediaType: vocab.MimeType(binaryFile.Mime()), MediaType: vocab.MimeType(binaryFile.Mime()),
URL: vocab.ID(fullImageUrl), URL: vocab.ID(fullImageUrl),
Name: vocab.NaturalLanguageValues{
{Value: vocab.Content(content)},
},
}) })
image := vocab.Note{ image := vocab.Image{
ID: vocab.ID(imageEntry.FullUrl(siteCfg)), ID: vocab.ID(imageEntry.FullUrl(siteCfg)),
Type: "Note", Type: "Image",
To: vocab.ItemCollection{ To: vocab.ItemCollection{
vocab.PublicNS, vocab.PublicNS,
vocab.IRI(svc.FollowersUrl()), vocab.IRI(svc.FollowersUrl()),
@ -610,7 +654,7 @@ func (svc *ActivityPubService) imageToObject(imageEntry *entrytypes.Image) vocab
{Value: vocab.Content(imageEntry.Title())}, {Value: vocab.Content(imageEntry.Title())},
}, },
Content: vocab.NaturalLanguageValues{ Content: vocab.NaturalLanguageValues{
{Value: vocab.Content(content)}, {Value: vocab.Content(imageEntry.Title() + "<br><br>" + string(content))},
}, },
Attachment: attachments, Attachment: attachments,
// Tag: tags, // Tag: tags,
@ -618,3 +662,51 @@ func (svc *ActivityPubService) imageToObject(imageEntry *entrytypes.Image) vocab
return image 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

@ -48,7 +48,13 @@ func (s *EntryService) Create(entry model.Entry) error {
if err != nil { if err != nil {
return err return err
} }
s.Bus.NotifyCreated(entry) // 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)
}
return nil return nil
} }
@ -57,7 +63,13 @@ func (s *EntryService) Update(entry model.Entry) error {
if err != nil { if err != nil {
return err return err
} }
s.Bus.NotifyUpdated(entry) // 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)
}
return nil return nil
} }
@ -66,6 +78,9 @@ func (s *EntryService) Delete(entry model.Entry) error {
if err != nil { if err != nil {
return err 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) s.Bus.NotifyDeleted(entry)
return nil return nil
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -57,6 +57,7 @@ def test_following(client, inbox_url, followers_url, actor_url):
def test_unfollow(client, inbox_url, followers_url, actor_url): def test_unfollow(client, inbox_url, followers_url, actor_url):
ensure_follow(client, inbox_url, actor_url) ensure_follow(client, inbox_url, actor_url)
sleep(0.5)
with msg_inc(1): with msg_inc(1):
req = sign( req = sign(
"POST", "POST",

View File

@ -13,7 +13,12 @@
<link rel="alternate" type="application/rss+xml" title="RSS" href="/index.xml"> <link rel="alternate" type="application/rss+xml" title="RSS" href="/index.xml">
<link rel='stylesheet' href='/static/owl.css'> <link rel='stylesheet' href='/static/owl.css'>
<link rel='stylesheet' href='/static/style.css'> <link rel='stylesheet' href='/static/style.css'>
<style>
:root {
--primary: {{.SiteConfig.PrimaryColor}};
}
</style>
{{ .SiteConfig.HtmlHeadExtra }} {{ .SiteConfig.HtmlHeadExtra }}
</head> </head>
<body> <body>
@ -67,6 +72,10 @@
<div> <div>
{{ .SiteConfig.FooterExtra}} {{ .SiteConfig.FooterExtra}}
</div> </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>
</footer> </footer>
</body> </body>
</html> </html>

View File

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

View File

@ -39,7 +39,8 @@ func NewWebApp(
apService *app.ActivityPubService, apService *app.ActivityPubService,
) *WebApp { ) *WebApp {
fiberApp := fiber.New(fiber.Config{ fiberApp := fiber.New(fiber.Config{
BodyLimit: 50 * 1024 * 1024, // 50MB in bytes BodyLimit: 50 * 1024 * 1024, // 50MB in bytes
DisableStartupMessage: true,
}) })
fiberApp.Use(middleware.NewUserMiddleware(authorService).Handle) fiberApp.Use(middleware.NewUserMiddleware(authorService).Handle)

View File

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