Compare commits
266 Commits
refactor-w
...
main
Author | SHA1 | Date |
---|---|---|
Niko Abeler | da06541e11 | |
Niko Abeler | 7b047a609f | |
Niko Abeler | a5f24427a1 | |
Niko Abeler | ec13fffbe9 | |
Niko Abeler | 686cd72ec2 | |
Niko Abeler | 29e875d2e5 | |
Niko Abeler | f958fa36fd | |
Niko Abeler | 1b347bcdac | |
Niko Abeler | c8b759a834 | |
Niko Abeler | 943bc10eaf | |
Niko Abeler | 4543e448ed | |
Niko Abeler | e0d6f4f223 | |
Niko Abeler | 49a602f68b | |
Niko Abeler | 596ab0047e | |
Niko Abeler | 5c05f48be3 | |
Niko Abeler | 3c924ac8a4 | |
Niko Abeler | 26737ea21d | |
Niko Abeler | 29ae7e717f | |
Niko Abeler | 741ccfac73 | |
Niko Abeler | 9cfbf0b9b7 | |
Niko Abeler | cba57ba708 | |
Niko Abeler | ced3907880 | |
Niko Abeler | 3cbf952ae6 | |
Niko Abeler | 0c8779def7 | |
Niko Abeler | 652f81805d | |
Niko Abeler | 624f19a1d9 | |
Niko Abeler | 10ca2bdcd9 | |
Niko Abeler | 633c0991e9 | |
Niko Abeler | 2bf2e409b6 | |
Niko Abeler | 390a58b404 | |
Niko Abeler | d7e5df2a95 | |
Niko Abeler | 3a3655b587 | |
Niko Abeler | cba07e961d | |
Niko Abeler | 1524820d5e | |
Niko Abeler | 91b82f0e57 | |
Niko Abeler | 2a4b76ee03 | |
Niko Abeler | 4bdb920c71 | |
Niko Abeler | ff10f6c5eb | |
Niko Abeler | bc7b146e91 | |
Niko Abeler | 7eb3bf0b44 | |
Niko Abeler | 5cc55a79ff | |
Niko Abeler | be62bcd627 | |
Niko Abeler | d794ad0865 | |
Niko Abeler | f5946ea823 | |
Niko Abeler | 8200e3384c | |
Niko Abeler | 0bf7c492c9 | |
Niko Abeler | 765698b1a6 | |
Niko Abeler | 1485a7efbe | |
Niko Abeler | bc50388f58 | |
Niko Abeler | 7ceb00799a | |
Niko Abeler | 6d115dd74e | |
Niko Abeler | 4941b5d027 | |
Niko Abeler | 2f81bf8678 | |
h4kor | c36b9abbcf | |
Niko Abeler | 1613d3dd11 | |
Niko Abeler | bd11b88338 | |
Niko Abeler | 1fdcdff41d | |
Niko Abeler | eee2131a76 | |
Niko Abeler | fcc0132758 | |
Niko Abeler | b86eee27ce | |
Niko Abeler | 50997c051b | |
Niko Abeler | 4bbaf3362e | |
Niko Abeler | c196a72d34 | |
Niko Abeler | 94b6628411 | |
Niko Abeler | 8d3bdb6f4c | |
Niko Abeler | 30e54f99fb | |
Niko Abeler | 0bbcde6ff6 | |
Niko Abeler | b0a6a0d417 | |
Niko Abeler | ad8cbbf556 | |
Niko Abeler | 08678e2697 | |
Niko Abeler | cd116b9a57 | |
Niko Abeler | a89aa7ee27 | |
Niko Abeler | 9322e59b96 | |
Niko Abeler | d4351af8f1 | |
Niko Abeler | 975761af2f | |
Niko Abeler | 55fb101ab5 | |
Niko Abeler | 653efcc487 | |
Niko Abeler | 723a6000bf | |
Niko Abeler | df5215d943 | |
Niko Abeler | 3f7b1bae50 | |
Niko Abeler | 6ab9af2d53 | |
Niko Abeler | b1c46a86aa | |
Niko Abeler | 5939bbd09d | |
Niko Abeler | 304db7ec03 | |
Niko Abeler | fb824ac07e | |
Niko Abeler | 482a962351 | |
Niko Abeler | 74760707dd | |
Niko Abeler | 349aa0dd5d | |
Niko Abeler | 70902af7e0 | |
Niko Abeler | 42d960f185 | |
Niko Abeler | 9e64672f9c | |
Niko Abeler | 080614e83f | |
Niko Abeler | ebca3e84f2 | |
Niko Abeler | e998272f81 | |
Niko Abeler | 7ead1ffe22 | |
Niko Abeler | 0de18e89fc | |
Niko Abeler | 40117dab37 | |
Niko Abeler | 5e77fdd33b | |
Niko Abeler | 5f49754b71 | |
Niko Abeler | 34e4984af9 | |
Niko Abeler | e35bf9af5d | |
Niko Abeler | e628735ab2 | |
Niko Abeler | a610515d5e | |
Niko Abeler | ea9059f9f0 | |
Niko Abeler | 802e9a399e | |
Niko Abeler | a38ebb61a9 | |
Niko Abeler | 68e8f84220 | |
Niko Abeler | 0a4106be9c | |
Niko Abeler | ea06b26c9f | |
Niko Abeler | d736679418 | |
Niko Abeler | e31dbeb18c | |
Niko Abeler | 128e38651d | |
Niko Abeler | 838e949e57 | |
h4kor | 2932f2f16c | |
Niko Abeler | 897f4094bf | |
Niko Abeler | 93184589bf | |
Niko Abeler | c24f2cb9db | |
Niko Abeler | b60980b368 | |
Niko Abeler | b488f9b032 | |
Niko Abeler | 1bf817465c | |
Niko Abeler | cc5ecff2f0 | |
Niko Abeler | 485ccb9090 | |
Niko Abeler | abbbbb4402 | |
Niko Abeler | 5f897cb677 | |
Niko Abeler | e91128fd7e | |
Niko Abeler | cecd94b296 | |
Niko Abeler | 3a1559584c | |
Niko Abeler | 033a6b4a7f | |
Niko Abeler | 687707a3e8 | |
Niko Abeler | 7c20b472f6 | |
Niko Abeler | 9c30ff7877 | |
Niko Abeler | 088d41e8a2 | |
Niko Abeler | 9921b1f91e | |
Niko Abeler | 34119d5642 | |
Niko Abeler | f513205cc3 | |
Niko Abeler | 408bb88cb8 | |
Niko Abeler | 723c94c576 | |
Niko Abeler | eaacf70140 | |
Niko Abeler | 1514e7533c | |
Niko Abeler | e9e17ed263 | |
Niko Abeler | a90bcaaa2d | |
Niko Abeler | 197629db9a | |
Niko Abeler | bcf8ba4d9b | |
Niko Abeler | 9301790408 | |
Niko Abeler | 28fd4f1dc2 | |
Niko Abeler | d3784b0337 | |
Niko Abeler | 8fc82b86a3 | |
Niko Abeler | ff193f62e9 | |
Niko Abeler | bcacbf1e4d | |
Niko Abeler | 936a0a0d80 | |
Niko Abeler | 1742728639 | |
Niko Abeler | 5d97d87c9b | |
Niko Abeler | 229f5833e0 | |
Niko Abeler | 7060c54989 | |
Niko Abeler | 4540797cce | |
Niko Abeler | 10e0bde07b | |
Niko Abeler | 98e9a3b9d2 | |
Niko Abeler | 1e5ea053cc | |
Niko Abeler | c7834b08d5 | |
Niko Abeler | 659aaa6c8d | |
Niko Abeler | 23d7afe9d4 | |
Niko Abeler | a49deabf07 | |
Niko Abeler | ff50adac78 | |
Niko Abeler | d783db98a2 | |
Niko Abeler | cad4897583 | |
Niko Abeler | 1fefa2a140 | |
Niko Abeler | b61e2ff50c | |
Niko Abeler | 9209f227c4 | |
Niko Abeler | bb5aca2bc1 | |
Niko Abeler | e2ad05cf0d | |
Niko Abeler | a717164334 | |
Niko Abeler | 8f2e2cd5f6 | |
Niko Abeler | 668eb658b2 | |
Niko Abeler | 51fd8cefe2 | |
Niko Abeler | 65fa4f6fb8 | |
Niko Abeler | 0d5e6599cf | |
Niko Abeler | 1f0856602a | |
Niko Abeler | c3563ce5ab | |
Niko Abeler | b169d4f6c0 | |
Niko Abeler | 29eea1d837 | |
Niko Abeler | 48a11ab6d7 | |
Niko Abeler | c67535c1d9 | |
Niko Abeler | 0db1c7a9ec | |
Niko Abeler | 03f37c6d9e | |
Niko Abeler | 18a593d2b1 | |
Niko Abeler | 58cd92a4b4 | |
Niko Abeler | 3fe828d31d | |
Niko Abeler | 521f7b16aa | |
Niko Abeler | e009505cb3 | |
Niko Abeler | f380f94043 | |
h4kor | b5dca6fa53 | |
Niko Abeler | 0934aaa121 | |
Niko Abeler | 396b84c9bb | |
Niko Abeler | ecc12333e1 | |
Niko Abeler | 33ca1fba6f | |
Niko Abeler | abc11e112f | |
Niko Abeler | 1039a905bc | |
Niko Abeler | 2246cae3f7 | |
Niko Abeler | fbf14704e7 | |
Niko Abeler | f377ce032a | |
Niko Abeler | b65230db88 | |
Niko Abeler | b83cd2f73c | |
Niko Abeler | 9c6a9cd499 | |
Niko Abeler | 25fbed4d44 | |
h4kor | 5c3b6351d8 | |
Niko Abeler | 23d07d56f7 | |
Niko Abeler | 0dc200e0b0 | |
Niko Abeler | 3495822ec3 | |
Niko Abeler | f2999ba53e | |
Niko Abeler | fc4f5a1623 | |
Niko Abeler | 703531834d | |
Niko Abeler | 37936a450f | |
Niko Abeler | 9389cc0266 | |
Niko Abeler | ff563c6f05 | |
Niko Abeler | fa30d4fd8e | |
Niko Abeler | 1d793c325b | |
Niko Abeler | 4d5af131c2 | |
Niko Abeler | ae387f3d7d | |
Niko Abeler | 3975f08441 | |
Niko Abeler | f3f72d8111 | |
Niko Abeler | 269262b381 | |
Niko Abeler | 2246cad5df | |
Niko Abeler | da9111c186 | |
Niko Abeler | 1072f48e9f | |
Niko Abeler | 2acca40afe | |
Niko Abeler | 7165e4c30a | |
Niko Abeler | 76ef9c1fe6 | |
Niko Abeler | 4aef1ca2ee | |
Niko Abeler | c92ab958a6 | |
Niko Abeler | 73def1b477 | |
Niko Abeler | 5f3978eaac | |
Niko Abeler | 8c1d7fd8c7 | |
Niko Abeler | 94918b5b62 | |
Niko Abeler | 7667190a9c | |
Niko Abeler | 5abd9f4b4b | |
Niko Abeler | 5fb96ee6fd | |
Niko Abeler | edf74aa330 | |
Niko Abeler | a42da82516 | |
Niko Abeler | 0a3af85b02 | |
Niko Abeler | bdb08657e3 | |
Niko Abeler | 9fe09af2e0 | |
Niko Abeler | 09aed165eb | |
Niko Abeler | 1c53244439 | |
Niko Abeler | 9056b22536 | |
Niko Abeler | 4cc69ff257 | |
Niko Abeler | 1179263818 | |
Niko Abeler | 7a70be9839 | |
Niko Abeler | e9bcaa4c4a | |
Niko Abeler | 968fb30f53 | |
Niko Abeler | 9ca50eafff | |
Niko Abeler | 3c669d0d5f | |
Niko Abeler | fac75dd273 | |
Niko Abeler | 216291a022 | |
Niko Abeler | 534dc3ba9b | |
Niko Abeler | ae29a0221c | |
Niko Abeler | 9559d27bf6 | |
Niko Abeler | e8184a5a4c | |
Niko Abeler | 41c2286311 | |
Niko Abeler | fe66d5842e | |
Niko Abeler | 881940cd88 | |
Niko Abeler | 4b9a5adf5c | |
Niko Abeler | d66c1a6817 | |
Niko Abeler | a8998068ad | |
Niko Abeler | ecfcd84b82 | |
Niko Abeler | db9dee6232 | |
h4kor | b3cb65bbc1 |
|
@ -0,0 +1,46 @@
|
||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = "/tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = ["web"]
|
||||||
|
bin = "/tmp/main"
|
||||||
|
cmd = "go build -buildvcs=false -o /tmp/main owl-blogs/cmd/owl"
|
||||||
|
delay = 1000
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = ""
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html", "css"]
|
||||||
|
include_file = []
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
post_cmd = []
|
||||||
|
pre_cmd = []
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
|
@ -0,0 +1,3 @@
|
||||||
|
e2e_tests/
|
||||||
|
tmp/
|
||||||
|
*.db
|
|
@ -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
|
|
@ -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
|
|
@ -24,3 +24,10 @@ users/
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
|
||||||
|
*.db
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
venv/
|
||||||
|
*.pyc
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
}
|
|
12
Dockerfile
12
Dockerfile
|
@ -1,10 +1,10 @@
|
||||||
##
|
##
|
||||||
## Build Container
|
## Build Container
|
||||||
##
|
##
|
||||||
FROM golang:1.19-alpine as build
|
FROM golang:1.22-alpine as build
|
||||||
|
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache --update git gcc g++
|
||||||
|
|
||||||
WORKDIR /tmp/owl
|
WORKDIR /tmp/owl
|
||||||
|
|
||||||
|
@ -15,19 +15,21 @@ RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN go build -o ./out/owl ./cmd/owl
|
RUN CGO_ENABLED=1 GOOS=linux go build -o ./out/owl ./cmd/owl
|
||||||
|
|
||||||
|
|
||||||
##
|
##
|
||||||
## Run Container
|
## Run Container
|
||||||
##
|
##
|
||||||
FROM alpine:3.9
|
FROM alpine
|
||||||
RUN apk add ca-certificates
|
RUN apk add ca-certificates
|
||||||
|
|
||||||
COPY --from=build /tmp/owl/out/ /bin/
|
COPY --from=build /tmp/owl/out/ /bin/
|
||||||
|
|
||||||
# This container exposes port 8080 to the outside world
|
# This container exposes port 8080 to the outside world
|
||||||
EXPOSE 8080
|
EXPOSE 3000
|
||||||
|
|
||||||
|
WORKDIR /owl
|
||||||
|
|
||||||
# Run the binary program produced by `go install`
|
# Run the binary program produced by `go install`
|
||||||
ENTRYPOINT ["/bin/owl"]
|
ENTRYPOINT ["/bin/owl"]
|
100
README.md
100
README.md
|
@ -2,74 +2,60 @@
|
||||||
|
|
||||||
# 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.
|
||||||
|
|
||||||
## Repository
|
# Usage
|
||||||
|
|
||||||
A repository holds all data for a web server. It contains multiple users.
|
## Run
|
||||||
|
|
||||||
## User
|
To run the web server use the command:
|
||||||
|
|
||||||
A user has a collection of posts.
|
|
||||||
Each directory in the `/users/` directory of a repository is considered a user.
|
|
||||||
|
|
||||||
### User Directory structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
<user-name>/
|
owl web
|
||||||
\- public/
|
|
||||||
\- <post-name>
|
|
||||||
\- index.md
|
|
||||||
-- This will be rendered as the blog post.
|
|
||||||
-- Must be present for the blog post to be valid.
|
|
||||||
-- All other folders will be ignored
|
|
||||||
\- status.yml
|
|
||||||
-- Used to track various process status related to the post,
|
|
||||||
-- such as if a webmention was sent.
|
|
||||||
\- media/
|
|
||||||
-- Contains all media files used in the blog post.
|
|
||||||
-- All files in this folder will be publicly available
|
|
||||||
\- webmention/
|
|
||||||
\- <hash>.yml
|
|
||||||
-- Contains data for a received webmention
|
|
||||||
\- meta/
|
|
||||||
\- base.html
|
|
||||||
-- The template used to render all sites
|
|
||||||
\- VERSION
|
|
||||||
-- Contains the version string.
|
|
||||||
-- Used to determine compatibility in the future
|
|
||||||
\- config.yml
|
|
||||||
-- Contains settings global to the user.
|
|
||||||
-- For example: page title and style options
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Post
|
The blog will run on port 3000 (http://localhost:3000)
|
||||||
|
|
||||||
Posts are Markdown files with a mandatory metadata head.
|
To create a new account:
|
||||||
|
|
||||||
- The `title` will be added to the web page and does not have to be reapeated in the body. It will be used in any lists of posts.
|
|
||||||
- `aliases` are optional. They are used as permanent redirects to the actual blog page.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
---
|
owl new-author -u <name> -p <password>
|
||||||
title: My new Post
|
|
||||||
date: 13 Aug 2022 17:07 UTC
|
|
||||||
aliases:
|
|
||||||
- /my/new/post
|
|
||||||
- /old_blog_path/
|
|
||||||
---
|
|
||||||
|
|
||||||
Actual post
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To retrieve a list of all commands run:
|
||||||
#### status.yml
|
|
||||||
|
|
||||||
```
|
```
|
||||||
webmentions:
|
owl -h
|
||||||
- target: https://example.com/post
|
```
|
||||||
supported: true
|
|
||||||
scanned_at: 2021-08-13T17:07:00Z
|
# Development
|
||||||
last_sent_at: 2021-08-13T17:07:00Z
|
|
||||||
```
|
## 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`
|
||||||
|
|
|
@ -0,0 +1,712 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,101 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthorService struct {
|
||||||
|
repo repository.AuthorRepository
|
||||||
|
siteConfigService *SiteConfigService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthorService(repo repository.AuthorRepository, siteConfigService *SiteConfigService) *AuthorService {
|
||||||
|
return &AuthorService{repo: repo, siteConfigService: siteConfigService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashPassword(password string) (string, error) {
|
||||||
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 10)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthorService) Create(name string, password string) (*model.Author, error) {
|
||||||
|
hash, err := hashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.repo.Create(name, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthorService) SetPassword(name string, password string) error {
|
||||||
|
hash, err := hashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
author, err := s.repo.FindByName(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
author.PasswordHash = hash
|
||||||
|
err = s.repo.Update(author)
|
||||||
|
return err
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthorService) FindByName(name string) (*model.Author, error) {
|
||||||
|
return s.repo.FindByName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthorService) Authenticate(name string, password string) bool {
|
||||||
|
author, err := s.repo.FindByName(name)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(author.PasswordHash), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthorService) getSecretKey() string {
|
||||||
|
siteConfig, err := s.siteConfigService.GetSiteConfig()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if siteConfig.Secret == "" {
|
||||||
|
siteConfig.Secret = RandStringRunes(64)
|
||||||
|
err = s.siteConfigService.UpdateSiteConfig(siteConfig)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return siteConfig.Secret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthorService) CreateToken(name string) (string, error) {
|
||||||
|
hash := sha256.New()
|
||||||
|
_, err := hash.Write([]byte(name + s.getSecretKey()))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s.%x", name, hash.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthorService) ValidateToken(token string) (bool, string) {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
witness := parts[len(parts)-1]
|
||||||
|
name := strings.Join(parts[:len(parts)-1], ".")
|
||||||
|
|
||||||
|
hash := sha256.New()
|
||||||
|
_, err := hash.Write([]byte(name + s.getSecretKey()))
|
||||||
|
if err != nil {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%x", hash.Sum(nil)) == witness, name
|
||||||
|
}
|
|
@ -0,0 +1,95 @@
|
||||||
|
package app_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/app"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"owl-blogs/infra"
|
||||||
|
"owl-blogs/test"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testConfigRepo struct {
|
||||||
|
config model.SiteConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get implements repository.SiteConfigRepository.
|
||||||
|
func (c *testConfigRepo) Get(name string, result interface{}) error {
|
||||||
|
*result.(*model.SiteConfig) = c.config
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update implements repository.SiteConfigRepository.
|
||||||
|
func (c *testConfigRepo) Update(name string, result interface{}) error {
|
||||||
|
c.config = result.(model.SiteConfig)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAutherService() *app.AuthorService {
|
||||||
|
db := test.NewMockDb()
|
||||||
|
authorRepo := infra.NewDefaultAuthorRepo(db)
|
||||||
|
authorService := app.NewAuthorService(authorRepo, app.NewSiteConfigService(&testConfigRepo{}))
|
||||||
|
return authorService
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorCreate(t *testing.T) {
|
||||||
|
authorService := getAutherService()
|
||||||
|
author, err := authorService.Create("test", "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "test", author.Name)
|
||||||
|
require.NotEmpty(t, author.PasswordHash)
|
||||||
|
require.NotEqual(t, "test", author.PasswordHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorFindByName(t *testing.T) {
|
||||||
|
authorService := getAutherService()
|
||||||
|
_, err := authorService.Create("test", "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
author, err := authorService.FindByName("test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "test", author.Name)
|
||||||
|
require.NotEmpty(t, author.PasswordHash)
|
||||||
|
require.NotEqual(t, "test", author.PasswordHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorAuthenticate(t *testing.T) {
|
||||||
|
authorService := getAutherService()
|
||||||
|
_, err := authorService.Create("test", "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, authorService.Authenticate("test", "test"))
|
||||||
|
require.False(t, authorService.Authenticate("test", "test1"))
|
||||||
|
require.False(t, authorService.Authenticate("test1", "test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorCreateToken(t *testing.T) {
|
||||||
|
authorService := getAutherService()
|
||||||
|
_, err := authorService.Create("test", "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
token, err := authorService.CreateToken("test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, token)
|
||||||
|
require.NotEqual(t, "test", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorValidateToken(t *testing.T) {
|
||||||
|
authorService := getAutherService()
|
||||||
|
_, err := authorService.Create("test", "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
token, err := authorService.CreateToken("test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
valid, name := authorService.ValidateToken(token)
|
||||||
|
require.True(t, valid)
|
||||||
|
require.Equal(t, "test", name)
|
||||||
|
valid, _ = authorService.ValidateToken(token[:len(token)-2])
|
||||||
|
require.False(t, valid)
|
||||||
|
valid, _ = authorService.ValidateToken("test")
|
||||||
|
require.False(t, valid)
|
||||||
|
valid, _ = authorService.ValidateToken("test.test")
|
||||||
|
require.False(t, valid)
|
||||||
|
valid, _ = authorService.ValidateToken(strings.Replace(token, "test", "test1", 1))
|
||||||
|
require.False(t, valid)
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BinaryService struct {
|
||||||
|
repo repository.BinaryRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBinaryFileService(repo repository.BinaryRepository) *BinaryService {
|
||||||
|
return &BinaryService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BinaryService) Create(name string, file []byte) (*model.BinaryFile, error) {
|
||||||
|
return s.repo.Create(name, file, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BinaryService) CreateEntryFile(name string, file []byte, entry model.Entry) (*model.BinaryFile, error) {
|
||||||
|
return s.repo.Create(name, file, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BinaryService) FindById(id string) (*model.BinaryFile, error) {
|
||||||
|
return s.repo.FindById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIds list all ids of binary files
|
||||||
|
// if filter is not empty, the list will be filter to all ids which include the filter filter substring
|
||||||
|
// ids and filters are compared in lower case
|
||||||
|
func (s *BinaryService) ListIds(filter string) ([]string, error) {
|
||||||
|
return s.repo.ListIds(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BinaryService) Delete(binary *model.BinaryFile) error {
|
||||||
|
return s.repo.Delete(binary)
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import "owl-blogs/domain/model"
|
||||||
|
|
||||||
|
type AppConfig interface {
|
||||||
|
model.Formable
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigRegister struct {
|
||||||
|
configs map[string]AppConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisteredConfig struct {
|
||||||
|
Name string
|
||||||
|
Config AppConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfigRegister() *ConfigRegister {
|
||||||
|
return &ConfigRegister{configs: map[string]AppConfig{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ConfigRegister) Register(name string, config AppConfig) {
|
||||||
|
r.configs[name] = config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ConfigRegister) Configs() []RegisteredConfig {
|
||||||
|
var configs []RegisteredConfig
|
||||||
|
for name, config := range r.configs {
|
||||||
|
configs = append(configs, RegisteredConfig{
|
||||||
|
Name: name,
|
||||||
|
Config: config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return configs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ConfigRegister) GetConfig(name string) AppConfig {
|
||||||
|
return r.configs[name]
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EntryTypeRegistry = TypeRegistry[model.Entry]
|
||||||
|
|
||||||
|
func NewEntryTypeRegistry() *EntryTypeRegistry {
|
||||||
|
return NewTypeRegistry[model.Entry]()
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
package app_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/app"
|
||||||
|
"owl-blogs/test"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegistryTypeNameNotExisting(t *testing.T) {
|
||||||
|
register := app.NewEntryTypeRegistry()
|
||||||
|
_, err := register.TypeName(&test.MockEntry{})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryTypeName(t *testing.T) {
|
||||||
|
register := app.NewEntryTypeRegistry()
|
||||||
|
register.Register(&test.MockEntry{})
|
||||||
|
name, err := register.TypeName(&test.MockEntry{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "MockEntry", name)
|
||||||
|
}
|
|
@ -0,0 +1,127 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EntryService struct {
|
||||||
|
EntryRepository repository.EntryRepository
|
||||||
|
siteConfigServcie *SiteConfigService
|
||||||
|
Bus *EventBus
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEntryService(
|
||||||
|
entryRepository repository.EntryRepository,
|
||||||
|
siteConfigServcie *SiteConfigService,
|
||||||
|
bus *EventBus,
|
||||||
|
) *EntryService {
|
||||||
|
return &EntryService{
|
||||||
|
EntryRepository: entryRepository,
|
||||||
|
siteConfigServcie: siteConfigServcie,
|
||||||
|
Bus: bus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EntryService) Create(entry model.Entry) error {
|
||||||
|
// try to find a good ID
|
||||||
|
m := regexp.MustCompile(`[^a-z0-9-]`)
|
||||||
|
prefix := m.ReplaceAllString(strings.ToLower(entry.Title()), "-")
|
||||||
|
title := prefix
|
||||||
|
counter := 0
|
||||||
|
for {
|
||||||
|
_, err := s.EntryRepository.FindById(title)
|
||||||
|
if err == nil {
|
||||||
|
counter += 1
|
||||||
|
title = prefix + "-" + fmt.Sprintf("%s-%d", prefix, counter)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.SetID(title)
|
||||||
|
|
||||||
|
err := s.EntryRepository.Create(entry)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EntryService) Update(entry model.Entry) error {
|
||||||
|
err := s.EntryRepository.Update(entry)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EntryService) Delete(entry model.Entry) error {
|
||||||
|
err := s.EntryRepository.Delete(entry)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if published && entry.PublishedAt() != nil && !entry.PublishedAt().IsZero() {
|
||||||
|
filteredEntries = append(filteredEntries, entry)
|
||||||
|
}
|
||||||
|
if drafts && (entry.PublishedAt() == nil || entry.PublishedAt().IsZero()) {
|
||||||
|
filteredEntries = append(filteredEntries, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filteredEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EntryService) FindAllByType(types *[]string, published bool, drafts bool) ([]model.Entry, error) {
|
||||||
|
entries, err := s.EntryRepository.FindAll(types)
|
||||||
|
return s.filterEntries(entries, published, drafts), err
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EntryService) FindAll() ([]model.Entry, error) {
|
||||||
|
entries, err := s.EntryRepository.FindAll(nil)
|
||||||
|
return s.filterEntries(entries, true, true), err
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
package app_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/app"
|
||||||
|
"owl-blogs/infra"
|
||||||
|
"owl-blogs/test"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupService() *app.EntryService {
|
||||||
|
db := test.NewMockDb()
|
||||||
|
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())
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNiceEntryId(t *testing.T) {
|
||||||
|
service := setupService()
|
||||||
|
entry := &test.MockEntry{}
|
||||||
|
meta := test.MockEntryMetaData{
|
||||||
|
Title: "Hello World",
|
||||||
|
}
|
||||||
|
entry.SetMetaData(&meta)
|
||||||
|
|
||||||
|
err := service.Create(entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "hello-world", entry.ID())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoTitleCreation(t *testing.T) {
|
||||||
|
service := setupService()
|
||||||
|
entry := &test.MockEntry{}
|
||||||
|
meta := test.MockEntryMetaData{
|
||||||
|
Title: "",
|
||||||
|
}
|
||||||
|
entry.SetMetaData(&meta)
|
||||||
|
|
||||||
|
err := service.Create(entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, "", entry.ID())
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import "owl-blogs/domain/model"
|
||||||
|
|
||||||
|
type Subscriber interface{}
|
||||||
|
type EntryCreatedSubscriber interface {
|
||||||
|
NotifyEntryCreated(entry model.Entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntryUpdatedSubscriber interface {
|
||||||
|
NotifyEntryUpdated(entry model.Entry)
|
||||||
|
}
|
||||||
|
type EntryDeletedSubscriber interface {
|
||||||
|
NotifyEntryDeleted(entry model.Entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventBus struct {
|
||||||
|
subscribers []Subscriber
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEventBus() *EventBus {
|
||||||
|
return &EventBus{subscribers: make([]Subscriber, 0)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *EventBus) Subscribe(subscriber Subscriber) {
|
||||||
|
b.subscribers = append(b.subscribers, subscriber)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *EventBus) NotifyCreated(entry model.Entry) {
|
||||||
|
for _, subscriber := range b.subscribers {
|
||||||
|
if sub, ok := subscriber.(EntryCreatedSubscriber); ok {
|
||||||
|
go sub.NotifyEntryCreated(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *EventBus) NotifyUpdated(entry model.Entry) {
|
||||||
|
for _, subscriber := range b.subscribers {
|
||||||
|
if sub, ok := subscriber.(EntryUpdatedSubscriber); ok {
|
||||||
|
go sub.NotifyEntryUpdated(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *EventBus) NotifyDeleted(entry model.Entry) {
|
||||||
|
for _, subscriber := range b.subscribers {
|
||||||
|
if sub, ok := subscriber.(EntryDeletedSubscriber); ok {
|
||||||
|
go sub.NotifyEntryDeleted(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TypeRegistry[T any] struct {
|
||||||
|
types map[string]T
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTypeRegistry[T any]() *TypeRegistry[T] {
|
||||||
|
return &TypeRegistry[T]{types: map[string]T{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TypeRegistry[T]) entryType(entry T) string {
|
||||||
|
return reflect.TypeOf(entry).Elem().Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TypeRegistry[T]) Register(entry T) error {
|
||||||
|
t := r.entryType(entry)
|
||||||
|
if _, ok := r.types[t]; ok {
|
||||||
|
return errors.New("entry type already registered")
|
||||||
|
}
|
||||||
|
r.types[t] = entry
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TypeRegistry[T]) Types() []T {
|
||||||
|
types := []T{}
|
||||||
|
for _, t := range r.types {
|
||||||
|
types = append(types, t)
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TypeRegistry[T]) TypeName(entry T) (string, error) {
|
||||||
|
t := r.entryType(entry)
|
||||||
|
if _, ok := r.types[t]; !ok {
|
||||||
|
return "", errors.New("entry type not registered")
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TypeRegistry[T]) Type(name string) (T, error) {
|
||||||
|
if _, ok := r.types[name]; !ok {
|
||||||
|
return *new(T), errors.New("entry type not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
val := reflect.ValueOf(r.types[name])
|
||||||
|
if val.Kind() == reflect.Ptr {
|
||||||
|
val = reflect.Indirect(val)
|
||||||
|
}
|
||||||
|
newEntry := reflect.New(val.Type()).Interface().(T)
|
||||||
|
|
||||||
|
return newEntry, nil
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package owl
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
@ -7,42 +7,18 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebmentionIn struct {
|
|
||||||
Source string `yaml:"source"`
|
|
||||||
Title string `yaml:"title"`
|
|
||||||
ApprovalStatus string `yaml:"approval_status"`
|
|
||||||
RetrievedAt time.Time `yaml:"retrieved_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WebmentionOut struct {
|
|
||||||
Target string `yaml:"target"`
|
|
||||||
Supported bool `yaml:"supported"`
|
|
||||||
ScannedAt time.Time `yaml:"scanned_at"`
|
|
||||||
LastSentAt time.Time `yaml:"last_sent_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type HttpClient interface {
|
|
||||||
Get(url string) (resp *http.Response, err error)
|
|
||||||
Post(url, contentType string, body io.Reader) (resp *http.Response, err error)
|
|
||||||
PostForm(url string, data url.Values) (resp *http.Response, err error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type HtmlParser interface {
|
type HtmlParser interface {
|
||||||
ParseHEntry(resp *http.Response) (ParsedHEntry, error)
|
ParseHEntry(resp *http.Response) (ParsedHEntry, error)
|
||||||
ParseLinks(resp *http.Response) ([]string, error)
|
ParseLinks(resp *http.Response) ([]string, error)
|
||||||
ParseLinksFromString(string) ([]string, error)
|
ParseLinksFromString(string) ([]string, error)
|
||||||
GetWebmentionEndpoint(resp *http.Response) (string, error)
|
GetWebmentionEndpoint(resp *http.Response) (string, error)
|
||||||
|
GetRedirctUris(resp *http.Response) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type OwlHttpClient = http.Client
|
|
||||||
|
|
||||||
type OwlHtmlParser struct{}
|
|
||||||
|
|
||||||
type ParsedHEntry struct {
|
type ParsedHEntry struct {
|
||||||
Title string
|
Title string
|
||||||
}
|
}
|
||||||
|
@ -66,7 +42,7 @@ func readResponseBody(resp *http.Response) (string, error) {
|
||||||
return string(bodyBytes), nil
|
return string(bodyBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (OwlHtmlParser) ParseHEntry(resp *http.Response) (ParsedHEntry, error) {
|
func ParseHEntry(resp *http.Response) (ParsedHEntry, error) {
|
||||||
htmlStr, err := readResponseBody(resp)
|
htmlStr, err := readResponseBody(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ParsedHEntry{}, err
|
return ParsedHEntry{}, err
|
||||||
|
@ -113,15 +89,15 @@ func (OwlHtmlParser) ParseHEntry(resp *http.Response) (ParsedHEntry, error) {
|
||||||
return findHFeed(doc)
|
return findHFeed(doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (OwlHtmlParser) ParseLinks(resp *http.Response) ([]string, error) {
|
func ParseLinks(resp *http.Response) ([]string, error) {
|
||||||
htmlStr, err := readResponseBody(resp)
|
htmlStr, err := readResponseBody(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return []string{}, err
|
return []string{}, err
|
||||||
}
|
}
|
||||||
return OwlHtmlParser{}.ParseLinksFromString(htmlStr)
|
return ParseLinksFromString(htmlStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (OwlHtmlParser) ParseLinksFromString(htmlStr string) ([]string, error) {
|
func ParseLinksFromString(htmlStr string) ([]string, error) {
|
||||||
doc, err := html.Parse(strings.NewReader(htmlStr))
|
doc, err := html.Parse(strings.NewReader(htmlStr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return make([]string, 0), err
|
return make([]string, 0), err
|
||||||
|
@ -146,7 +122,7 @@ func (OwlHtmlParser) ParseLinksFromString(htmlStr string) ([]string, error) {
|
||||||
return findLinks(doc)
|
return findLinks(doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (OwlHtmlParser) GetWebmentionEndpoint(resp *http.Response) (string, error) {
|
func GetWebmentionEndpoint(resp *http.Response) (string, error) {
|
||||||
//request url
|
//request url
|
||||||
requestUrl := resp.Request.URL
|
requestUrl := resp.Request.URL
|
||||||
|
|
||||||
|
@ -219,3 +195,73 @@ func (OwlHtmlParser) GetWebmentionEndpoint(resp *http.Response) (string, error)
|
||||||
}
|
}
|
||||||
return requestUrl.ResolveReference(linkUrl).String(), nil
|
return requestUrl.ResolveReference(linkUrl).String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetRedirctUris(resp *http.Response) ([]string, error) {
|
||||||
|
//request url
|
||||||
|
requestUrl := resp.Request.URL
|
||||||
|
|
||||||
|
htmlStr, err := readResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return make([]string, 0), err
|
||||||
|
}
|
||||||
|
doc, err := html.Parse(strings.NewReader(htmlStr))
|
||||||
|
if err != nil {
|
||||||
|
return make([]string, 0), err
|
||||||
|
}
|
||||||
|
|
||||||
|
var findLinks func(*html.Node) ([]string, error)
|
||||||
|
// Check link headers
|
||||||
|
header_links := make([]string, 0)
|
||||||
|
for _, linkHeader := range resp.Header["Link"] {
|
||||||
|
linkHeaderParts := strings.Split(linkHeader, ",")
|
||||||
|
for _, linkHeaderPart := range linkHeaderParts {
|
||||||
|
linkHeaderPart = strings.TrimSpace(linkHeaderPart)
|
||||||
|
params := strings.Split(linkHeaderPart, ";")
|
||||||
|
if len(params) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, param := range params[1:] {
|
||||||
|
param = strings.TrimSpace(param)
|
||||||
|
if strings.Contains(param, "redirect_uri") {
|
||||||
|
link := strings.Split(params[0], ";")[0]
|
||||||
|
link = strings.Trim(link, "<>")
|
||||||
|
linkUrl, err := url.Parse(link)
|
||||||
|
if err == nil {
|
||||||
|
header_links = append(header_links, requestUrl.ResolveReference(linkUrl).String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findLinks = func(n *html.Node) ([]string, error) {
|
||||||
|
links := make([]string, 0)
|
||||||
|
if n.Type == html.ElementNode && n.Data == "link" {
|
||||||
|
// check for rel="redirect_uri"
|
||||||
|
rel := ""
|
||||||
|
href := ""
|
||||||
|
|
||||||
|
for _, attr := range n.Attr {
|
||||||
|
if attr.Key == "href" {
|
||||||
|
href = attr.Val
|
||||||
|
}
|
||||||
|
if attr.Key == "rel" {
|
||||||
|
rel = attr.Val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rel == "redirect_uri" {
|
||||||
|
linkUrl, err := url.Parse(href)
|
||||||
|
if err == nil {
|
||||||
|
links = append(links, requestUrl.ResolveReference(linkUrl).String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
childLinks, _ := findLinks(c)
|
||||||
|
links = append(links, childLinks...)
|
||||||
|
}
|
||||||
|
return links, nil
|
||||||
|
}
|
||||||
|
body_links, err := findLinks(doc)
|
||||||
|
return append(body_links, header_links...), err
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InteractionTypeRegistry = TypeRegistry[model.Interaction]
|
||||||
|
|
||||||
|
func NewInteractionTypeRegistry() *InteractionTypeRegistry {
|
||||||
|
return NewTypeRegistry[model.Interaction]()
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InteractionService struct {
|
||||||
|
repo repository.InteractionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InteractionService) ListInteractions() ([]model.Interaction, error) {
|
||||||
|
return s.repo.ListAllInteractions()
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
package owlhttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HttpClient interface {
|
||||||
|
Get(url string) (resp *http.Response, err error)
|
||||||
|
Post(url, contentType string, body io.Reader) (resp *http.Response, err error)
|
||||||
|
PostForm(url string, data url.Values) (resp *http.Response, err error)
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EntryRepository interface {
|
||||||
|
Create(entry model.Entry) error
|
||||||
|
Update(entry model.Entry) error
|
||||||
|
Delete(entry model.Entry) error
|
||||||
|
FindById(id string) (model.Entry, error)
|
||||||
|
FindAll(types *[]string) ([]model.Entry, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type BinaryRepository interface {
|
||||||
|
// Create creates a new binary file
|
||||||
|
// The name is the original file name, and is not unique
|
||||||
|
// BinaryFile.Id is a unique identifier
|
||||||
|
Create(name string, data []byte, entry model.Entry) (*model.BinaryFile, error)
|
||||||
|
FindById(id string) (*model.BinaryFile, error)
|
||||||
|
FindByNameForEntry(name string, entry model.Entry) (*model.BinaryFile, error)
|
||||||
|
// ListIds list all ids of binary files
|
||||||
|
// if filter is not empty, the list will be filter to all ids which include the filter filter substring
|
||||||
|
// ids and filters are compared in lower case
|
||||||
|
ListIds(filter string) ([]string, error)
|
||||||
|
Delete(binary *model.BinaryFile) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthorRepository interface {
|
||||||
|
// Create creates a new author
|
||||||
|
// It returns an error if the name is already taken
|
||||||
|
Create(name string, passwordHash string) (*model.Author, error)
|
||||||
|
|
||||||
|
Update(author *model.Author) error
|
||||||
|
// FindByName finds an author by name
|
||||||
|
// It returns an error if the author is not found
|
||||||
|
FindByName(name string) (*model.Author, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigRepository interface {
|
||||||
|
Get(name string, config interface{}) error
|
||||||
|
Update(name string, siteConfig interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type InteractionRepository interface {
|
||||||
|
Create(interaction model.Interaction) error
|
||||||
|
Update(interaction model.Interaction) error
|
||||||
|
Delete(interaction model.Interaction) error
|
||||||
|
FindById(id string) (model.Interaction, error)
|
||||||
|
FindAll(entryId string) ([]model.Interaction, error)
|
||||||
|
// 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)
|
||||||
|
}
|
|
@ -0,0 +1,58 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
"owl-blogs/config"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SiteConfigService is a service to retrieve and store the site config
|
||||||
|
// Even though the site config is a standard config, it is handle by an extra service
|
||||||
|
// as it is used in many places.
|
||||||
|
// The SiteConfig contains global settings require by multiple parts of the app
|
||||||
|
type SiteConfigService struct {
|
||||||
|
repo repository.ConfigRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSiteConfigService(repo repository.ConfigRepository) *SiteConfigService {
|
||||||
|
return &SiteConfigService{
|
||||||
|
repo: repo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *SiteConfigService) defaultConfig() model.SiteConfig {
|
||||||
|
return model.SiteConfig{
|
||||||
|
Title: "My Owl-Blog",
|
||||||
|
SubTitle: "A freshly created blog",
|
||||||
|
PrimaryColor: "#d37f12",
|
||||||
|
AuthorName: "",
|
||||||
|
Me: []model.MeLinks{},
|
||||||
|
Lists: []model.EntryList{},
|
||||||
|
PrimaryListInclude: []string{},
|
||||||
|
HeaderMenu: []model.MenuItem{},
|
||||||
|
FooterMenu: []model.MenuItem{},
|
||||||
|
Secret: "",
|
||||||
|
AvatarUrl: "",
|
||||||
|
FullUrl: "http://localhost:3000",
|
||||||
|
HtmlHeadExtra: "",
|
||||||
|
FooterExtra: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *SiteConfigService) GetSiteConfig() (model.SiteConfig, error) {
|
||||||
|
siteConfig := model.SiteConfig{}
|
||||||
|
err := svc.repo.Get(config.SITE_CONFIG, &siteConfig)
|
||||||
|
if err != nil {
|
||||||
|
println("ERROR IN SITE CONFIG")
|
||||||
|
return model.SiteConfig{}, err
|
||||||
|
}
|
||||||
|
if reflect.ValueOf(siteConfig).IsZero() {
|
||||||
|
return svc.defaultConfig(), nil
|
||||||
|
}
|
||||||
|
return siteConfig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *SiteConfigService) UpdateSiteConfig(cfg model.SiteConfig) error {
|
||||||
|
return svc.repo.Update(config.SITE_CONFIG, cfg)
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||||
|
|
||||||
|
func RandStringRunes(n int) string {
|
||||||
|
b := make([]rune, n)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = letterRunes[rand.Intn(len(letterRunes))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UrlToEntryId(url string) string {
|
||||||
|
parts := strings.Split(url, "/")
|
||||||
|
if parts[len(parts)-1] == "" {
|
||||||
|
return parts[len(parts)-2]
|
||||||
|
} else {
|
||||||
|
return parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,151 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"owl-blogs/app/owlhttp"
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"owl-blogs/interactions"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebmentionService struct {
|
||||||
|
siteConfigService *SiteConfigService
|
||||||
|
InteractionRepository repository.InteractionRepository
|
||||||
|
EntryRepository repository.EntryRepository
|
||||||
|
Http owlhttp.HttpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebmentionService(
|
||||||
|
siteConfigService *SiteConfigService,
|
||||||
|
interactionRepository repository.InteractionRepository,
|
||||||
|
entryRepository repository.EntryRepository,
|
||||||
|
http owlhttp.HttpClient,
|
||||||
|
bus *EventBus,
|
||||||
|
) *WebmentionService {
|
||||||
|
svc := &WebmentionService{
|
||||||
|
siteConfigService: siteConfigService,
|
||||||
|
InteractionRepository: interactionRepository,
|
||||||
|
EntryRepository: entryRepository,
|
||||||
|
Http: http,
|
||||||
|
}
|
||||||
|
bus.Subscribe(svc)
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebmentionService) GetExistingWebmention(entryId string, source string, target string) (*interactions.Webmention, error) {
|
||||||
|
inters, err := s.InteractionRepository.FindAll(entryId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, interaction := range inters {
|
||||||
|
if webm, ok := interaction.(*interactions.Webmention); ok {
|
||||||
|
m := webm.MetaData().(*interactions.WebmentionMetaData)
|
||||||
|
if m.Source == source && m.Target == target {
|
||||||
|
return webm, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebmentionService) ProcessWebmention(source string, target string) error {
|
||||||
|
resp, err := s.Http.Get(source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hEntry, err := ParseHEntry(resp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
entryId := UrlToEntryId(target)
|
||||||
|
_, err = s.EntryRepository.FindById(entryId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
webmention, err := s.GetExistingWebmention(entryId, source, target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if webmention != nil {
|
||||||
|
data := interactions.WebmentionMetaData{
|
||||||
|
Source: source,
|
||||||
|
Target: target,
|
||||||
|
Title: hEntry.Title,
|
||||||
|
}
|
||||||
|
webmention.SetMetaData(&data)
|
||||||
|
webmention.SetEntryID(entryId)
|
||||||
|
webmention.SetCreatedAt(time.Now())
|
||||||
|
err = s.InteractionRepository.Update(webmention)
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
webmention = &interactions.Webmention{}
|
||||||
|
data := interactions.WebmentionMetaData{
|
||||||
|
Source: source,
|
||||||
|
Target: target,
|
||||||
|
Title: hEntry.Title,
|
||||||
|
}
|
||||||
|
webmention.SetMetaData(&data)
|
||||||
|
webmention.SetEntryID(entryId)
|
||||||
|
webmention.SetCreatedAt(time.Now())
|
||||||
|
err = s.InteractionRepository.Create(webmention)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebmentionService) ScanForLinks(entry model.Entry) ([]string, error) {
|
||||||
|
content := string(entry.Content())
|
||||||
|
return ParseLinksFromString(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebmentionService) FullEntryUrl(entry model.Entry) string {
|
||||||
|
siteConfig, _ := s.siteConfigService.GetSiteConfig()
|
||||||
|
|
||||||
|
url, _ := url.JoinPath(
|
||||||
|
siteConfig.FullUrl,
|
||||||
|
fmt.Sprintf("/posts/%s/", entry.ID()),
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebmentionService) SendWebmention(entry model.Entry) error {
|
||||||
|
links, err := s.ScanForLinks(entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, target := range links {
|
||||||
|
resp, err := s.Http.Get(target)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
endpoint, err := GetWebmentionEndpoint(resp)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload := url.Values{}
|
||||||
|
payload.Set("source", s.FullEntryUrl(entry))
|
||||||
|
payload.Set("target", target)
|
||||||
|
_, err = s.Http.PostForm(endpoint, payload)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
println("Send webmention for target", target)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebmentionService) NotifyEntryCreated(entry model.Entry) {
|
||||||
|
s.SendWebmention(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebmentionService) NotifyEntryUpdated(entry model.Entry) {
|
||||||
|
s.SendWebmention(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebmentionService) NotifyEntryDeleted(entry model.Entry) {
|
||||||
|
s.SendWebmention(entry)
|
||||||
|
}
|
|
@ -0,0 +1,217 @@
|
||||||
|
package app_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"owl-blogs/app"
|
||||||
|
"owl-blogs/infra"
|
||||||
|
"owl-blogs/interactions"
|
||||||
|
"owl-blogs/test"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func constructResponse(html []byte) *http.Response {
|
||||||
|
url, _ := url.Parse("http://example.com/foo/bar")
|
||||||
|
return &http.Response{
|
||||||
|
Request: &http.Request{
|
||||||
|
URL: url,
|
||||||
|
},
|
||||||
|
Body: io.NopCloser(bytes.NewReader([]byte(html))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockHttpClient struct {
|
||||||
|
PageContent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post implements owlhttp.HttpClient.
|
||||||
|
func (MockHttpClient) Post(url string, contentType string, body io.Reader) (resp *http.Response, err error) {
|
||||||
|
panic("unimplemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostForm implements owlhttp.HttpClient.
|
||||||
|
func (MockHttpClient) PostForm(url string, data url.Values) (resp *http.Response, err error) {
|
||||||
|
panic("unimplemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MockHttpClient) Get(url string) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
Body: io.NopCloser(bytes.NewReader([]byte(c.PageContent))),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWebmentionService() *app.WebmentionService {
|
||||||
|
db := test.NewMockDb()
|
||||||
|
entryRegister := app.NewEntryTypeRegistry()
|
||||||
|
entryRegister.Register(&test.MockEntry{})
|
||||||
|
entryRepo := infra.NewEntryRepository(db, entryRegister)
|
||||||
|
|
||||||
|
interactionRegister := app.NewInteractionTypeRegistry()
|
||||||
|
interactionRegister.Register(&interactions.Webmention{})
|
||||||
|
|
||||||
|
interactionRepo := infra.NewInteractionRepo(db, interactionRegister)
|
||||||
|
|
||||||
|
configRepo := infra.NewConfigRepo(db)
|
||||||
|
|
||||||
|
bus := app.NewEventBus()
|
||||||
|
|
||||||
|
http := infra.OwlHttpClient{}
|
||||||
|
return app.NewWebmentionService(
|
||||||
|
app.NewSiteConfigService(configRepo), interactionRepo, entryRepo, &http, bus,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// https://www.w3.org/TR/webmention/#h-webmention-verification
|
||||||
|
//
|
||||||
|
|
||||||
|
func TestParseValidHEntry(t *testing.T) {
|
||||||
|
html := []byte("<div class=\"h-entry\"><div class=\"p-name\">Foo</div></div>")
|
||||||
|
entry, err := app.ParseHEntry(&http.Response{Body: io.NopCloser(bytes.NewReader(html))})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, entry.Title, "Foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseValidHEntryWithoutTitle(t *testing.T) {
|
||||||
|
html := []byte("<div class=\"h-entry\"></div><div class=\"p-name\">Foo</div>")
|
||||||
|
entry, err := app.ParseHEntry(&http.Response{Body: io.NopCloser(bytes.NewReader(html))})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, entry.Title, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateNewWebmention(t *testing.T) {
|
||||||
|
service := getWebmentionService()
|
||||||
|
service.Http = &MockHttpClient{
|
||||||
|
PageContent: "<div class=\"h-entry\"><div class=\"p-name\">Foo</div></div>",
|
||||||
|
}
|
||||||
|
entry := test.MockEntry{}
|
||||||
|
service.EntryRepository.Create(&entry)
|
||||||
|
|
||||||
|
err := service.ProcessWebmention(
|
||||||
|
"http://example.com/foo",
|
||||||
|
fmt.Sprintf("https.//example.com/posts/%s/", entry.ID()),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
inters, err := service.InteractionRepository.FindAll(entry.ID())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, len(inters), 1)
|
||||||
|
webm := inters[0].(*interactions.Webmention)
|
||||||
|
meta := webm.MetaData().(*interactions.WebmentionMetaData)
|
||||||
|
require.Equal(t, meta.Source, "http://example.com/foo")
|
||||||
|
require.Equal(t, meta.Target, fmt.Sprintf("https.//example.com/posts/%s/", entry.ID()))
|
||||||
|
require.Equal(t, meta.Title, "Foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWebmentionEndpointLink(t *testing.T) {
|
||||||
|
html := []byte("<link rel=\"webmention\" href=\"http://example.com/webmention\" />")
|
||||||
|
endpoint, err := app.GetWebmentionEndpoint(constructResponse(html))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Equal(t, endpoint, "http://example.com/webmention")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWebmentionEndpointLinkA(t *testing.T) {
|
||||||
|
html := []byte("<a rel=\"webmention\" href=\"http://example.com/webmention\" />")
|
||||||
|
endpoint, err := app.GetWebmentionEndpoint(constructResponse(html))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, endpoint, "http://example.com/webmention")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWebmentionEndpointLinkAFakeWebmention(t *testing.T) {
|
||||||
|
html := []byte("<a rel=\"not-webmention\" href=\"http://example.com/foo\" /><a rel=\"webmention\" href=\"http://example.com/webmention\" />")
|
||||||
|
endpoint, err := app.GetWebmentionEndpoint(constructResponse(html))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, endpoint, "http://example.com/webmention")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWebmentionEndpointLinkHeader(t *testing.T) {
|
||||||
|
html := []byte("")
|
||||||
|
resp := constructResponse(html)
|
||||||
|
resp.Header = http.Header{"Link": []string{"<http://example.com/webmention>; rel=\"webmention\""}}
|
||||||
|
endpoint, err := app.GetWebmentionEndpoint(resp)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, endpoint, "http://example.com/webmention")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWebmentionEndpointLinkHeaderCommas(t *testing.T) {
|
||||||
|
html := []byte("")
|
||||||
|
resp := constructResponse(html)
|
||||||
|
resp.Header = http.Header{
|
||||||
|
"Link": []string{"<https://webmention.rocks/test/19/webmention/error>; rel=\"other\", <https://webmention.rocks/test/19/webmention>; rel=\"webmention\""},
|
||||||
|
}
|
||||||
|
endpoint, err := app.GetWebmentionEndpoint(resp)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, endpoint, "https://webmention.rocks/test/19/webmention")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWebmentionEndpointRelativeLink(t *testing.T) {
|
||||||
|
html := []byte("<link rel=\"webmention\" href=\"/webmention\" />")
|
||||||
|
endpoint, err := app.GetWebmentionEndpoint(constructResponse(html))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, endpoint, "http://example.com/webmention")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWebmentionEndpointRelativeLinkInHeader(t *testing.T) {
|
||||||
|
html := []byte("<link rel=\"webmention\" href=\"/webmention\" />")
|
||||||
|
resp := constructResponse(html)
|
||||||
|
resp.Header = http.Header{"Link": []string{"</webmention>; rel=\"webmention\""}}
|
||||||
|
endpoint, err := app.GetWebmentionEndpoint(resp)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, endpoint, "http://example.com/webmention")
|
||||||
|
}
|
||||||
|
|
||||||
|
// func TestRealWorldWebmention(t *testing.T) {
|
||||||
|
// service := getWebmentionService()
|
||||||
|
// links := []string{
|
||||||
|
// "https://webmention.rocks/test/1",
|
||||||
|
// "https://webmention.rocks/test/2",
|
||||||
|
// "https://webmention.rocks/test/3",
|
||||||
|
// "https://webmention.rocks/test/4",
|
||||||
|
// "https://webmention.rocks/test/5",
|
||||||
|
// "https://webmention.rocks/test/6",
|
||||||
|
// "https://webmention.rocks/test/7",
|
||||||
|
// "https://webmention.rocks/test/8",
|
||||||
|
// "https://webmention.rocks/test/9",
|
||||||
|
// // "https://webmention.rocks/test/10", // not supported
|
||||||
|
// "https://webmention.rocks/test/11",
|
||||||
|
// "https://webmention.rocks/test/12",
|
||||||
|
// "https://webmention.rocks/test/13",
|
||||||
|
// "https://webmention.rocks/test/14",
|
||||||
|
// "https://webmention.rocks/test/15",
|
||||||
|
// "https://webmention.rocks/test/16",
|
||||||
|
// "https://webmention.rocks/test/17",
|
||||||
|
// "https://webmention.rocks/test/18",
|
||||||
|
// "https://webmention.rocks/test/19",
|
||||||
|
// "https://webmention.rocks/test/20",
|
||||||
|
// "https://webmention.rocks/test/21",
|
||||||
|
// "https://webmention.rocks/test/22",
|
||||||
|
// "https://webmention.rocks/test/23/page",
|
||||||
|
// }
|
||||||
|
|
||||||
|
// for _, link := range links {
|
||||||
|
//
|
||||||
|
// client := &owl.OwlHttpClient{}
|
||||||
|
// html, _ := client.Get(link)
|
||||||
|
// _, err := app.GetWebmentionEndpoint(html)
|
||||||
|
|
||||||
|
// if err != nil {
|
||||||
|
// t.Errorf("Unable to find webmention: %v for link %v", err, link)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// }
|
|
@ -0,0 +1,29 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/infra"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var user string
|
||||||
|
var password string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(newAuthorCmd)
|
||||||
|
|
||||||
|
newAuthorCmd.Flags().StringVarP(&user, "user", "u", "", "The user name")
|
||||||
|
newAuthorCmd.MarkFlagRequired("user")
|
||||||
|
newAuthorCmd.Flags().StringVarP(&password, "password", "p", "", "The password")
|
||||||
|
newAuthorCmd.MarkFlagRequired("password")
|
||||||
|
}
|
||||||
|
|
||||||
|
var newAuthorCmd = &cobra.Command{
|
||||||
|
Use: "new-author",
|
||||||
|
Short: "Creates a new author",
|
||||||
|
Long: `Creates a new author`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
db := infra.NewSqliteDB(DbPath)
|
||||||
|
App(db).AuthorService.Create(user, password)
|
||||||
|
},
|
||||||
|
}
|
|
@ -0,0 +1,136 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"owl-blogs/app"
|
||||||
|
entrytypes "owl-blogs/entry_types"
|
||||||
|
"owl-blogs/infra"
|
||||||
|
"owl-blogs/test"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getUserToken(service *app.AuthorService) string {
|
||||||
|
_, err := service.Create("test", "test")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
token, err := service.CreateToken("test")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditorFormGet(t *testing.T) {
|
||||||
|
db := test.NewMockDb()
|
||||||
|
owlApp := App(db)
|
||||||
|
app := owlApp.FiberApp
|
||||||
|
token := getUserToken(owlApp.AuthorService)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/editor/new/Image", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "token", Value: token})
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditorFormGetNoAuth(t *testing.T) {
|
||||||
|
db := test.NewMockDb()
|
||||||
|
owlApp := App(db)
|
||||||
|
app := owlApp.FiberApp
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/editor/new/Image", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"})
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 302, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditorFormPost(t *testing.T) {
|
||||||
|
db := test.NewMockDb()
|
||||||
|
owlApp := App(db)
|
||||||
|
app := owlApp.FiberApp
|
||||||
|
token := getUserToken(owlApp.AuthorService)
|
||||||
|
repo := infra.NewEntryRepository(db, owlApp.Registry)
|
||||||
|
binRepo := infra.NewBinaryFileRepo(db)
|
||||||
|
|
||||||
|
fileDir, _ := os.Getwd()
|
||||||
|
fileName := "../../test/fixtures/test.png"
|
||||||
|
filePath := path.Join(fileDir, fileName)
|
||||||
|
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
fileBytes, err := ioutil.ReadFile(filePath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, _ := writer.CreateFormFile("image", filepath.Base(file.Name()))
|
||||||
|
io.Copy(part, file)
|
||||||
|
part, _ = writer.CreateFormField("content")
|
||||||
|
io.WriteString(part, "test content")
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/editor/new/Image", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.AddCookie(&http.Cookie{Name: "token", Value: token})
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 302, resp.StatusCode)
|
||||||
|
require.Contains(t, resp.Header.Get("Location"), "/posts/")
|
||||||
|
|
||||||
|
id := strings.Split(resp.Header.Get("Location"), "/")[2]
|
||||||
|
entry, err := repo.FindById(id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "test content", entry.MetaData().(*entrytypes.ImageMetaData).Content)
|
||||||
|
imageId := entry.MetaData().(*entrytypes.ImageMetaData).ImageId
|
||||||
|
require.NotZero(t, imageId)
|
||||||
|
bin, err := binRepo.FindById(imageId)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, bin.Name, "test.png")
|
||||||
|
require.Equal(t, fileBytes, bin.Data)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditorFormPostNoAuth(t *testing.T) {
|
||||||
|
db := test.NewMockDb()
|
||||||
|
owlApp := App(db)
|
||||||
|
app := owlApp.FiberApp
|
||||||
|
|
||||||
|
fileDir, _ := os.Getwd()
|
||||||
|
fileName := "../../test/fixtures/test.png"
|
||||||
|
filePath := path.Join(fileDir, fileName)
|
||||||
|
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, _ := writer.CreateFormFile("ImageId", filepath.Base(file.Name()))
|
||||||
|
io.Copy(part, file)
|
||||||
|
part, _ = writer.CreateFormField("Content")
|
||||||
|
io.WriteString(part, "test content")
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/editor/new/Image", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"})
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 302, resp.StatusCode)
|
||||||
|
require.Contains(t, resp.Header.Get("Location"), "/auth/login")
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,204 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"owl-blogs/config"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
entrytypes "owl-blogs/entry_types"
|
||||||
|
"owl-blogs/importer"
|
||||||
|
"owl-blogs/infra"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var userPath string
|
||||||
|
var author string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(importCmd)
|
||||||
|
|
||||||
|
importCmd.Flags().StringVarP(&userPath, "path", "p", "", "Path to the user folder")
|
||||||
|
importCmd.MarkFlagRequired("path")
|
||||||
|
importCmd.Flags().StringVarP(&author, "author", "a", "", "The author name")
|
||||||
|
importCmd.MarkFlagRequired("author")
|
||||||
|
}
|
||||||
|
|
||||||
|
var importCmd = &cobra.Command{
|
||||||
|
Use: "import",
|
||||||
|
Short: "Import data from v1",
|
||||||
|
Long: `Import data from v1`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
db := infra.NewSqliteDB(DbPath)
|
||||||
|
app := App(db)
|
||||||
|
|
||||||
|
posts, err := importer.AllUserPosts(userPath)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// import config
|
||||||
|
bytes, err := os.ReadFile(path.Join(userPath, "meta/config.yml"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
v1Config := importer.V1UserConfig{}
|
||||||
|
yaml.Unmarshal(bytes, &v1Config)
|
||||||
|
|
||||||
|
mes := []model.MeLinks{}
|
||||||
|
for _, me := range v1Config.Me {
|
||||||
|
mes = append(mes, model.MeLinks{
|
||||||
|
Name: me.Name,
|
||||||
|
Url: me.Url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
lists := []model.EntryList{}
|
||||||
|
for _, list := range v1Config.Lists {
|
||||||
|
lists = append(lists, model.EntryList{
|
||||||
|
Id: list.Id,
|
||||||
|
Title: list.Title,
|
||||||
|
Include: importer.ConvertTypeList(list.Include, app.Registry),
|
||||||
|
ListType: list.ListType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
headerMenu := []model.MenuItem{}
|
||||||
|
for _, item := range v1Config.HeaderMenu {
|
||||||
|
headerMenu = append(headerMenu, model.MenuItem{
|
||||||
|
Title: item.Title,
|
||||||
|
List: item.List,
|
||||||
|
Url: item.Url,
|
||||||
|
Post: item.Post,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
footerMenu := []model.MenuItem{}
|
||||||
|
for _, item := range v1Config.FooterMenu {
|
||||||
|
footerMenu = append(footerMenu, model.MenuItem{
|
||||||
|
Title: item.Title,
|
||||||
|
List: item.List,
|
||||||
|
Url: item.Url,
|
||||||
|
Post: item.Post,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
v2Config := &model.SiteConfig{}
|
||||||
|
err = app.SiteConfigRepo.Get(config.SITE_CONFIG, v2Config)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
v2Config.Title = v1Config.Title
|
||||||
|
v2Config.SubTitle = v1Config.SubTitle
|
||||||
|
v2Config.AuthorName = v1Config.AuthorName
|
||||||
|
v2Config.Me = mes
|
||||||
|
v2Config.Lists = lists
|
||||||
|
v2Config.PrimaryListInclude = importer.ConvertTypeList(v1Config.PrimaryListInclude, app.Registry)
|
||||||
|
v2Config.HeaderMenu = headerMenu
|
||||||
|
v2Config.FooterMenu = footerMenu
|
||||||
|
|
||||||
|
err = app.SiteConfigRepo.Update(config.SITE_CONFIG, v2Config)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, post := range posts {
|
||||||
|
existing, _ := app.EntryService.FindById(post.Id)
|
||||||
|
if existing != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Println(post.Meta.Type)
|
||||||
|
|
||||||
|
// import assets
|
||||||
|
mediaDir := path.Join(userPath, post.MediaDir())
|
||||||
|
println(mediaDir)
|
||||||
|
files := importer.ListDir(mediaDir)
|
||||||
|
for _, file := range files {
|
||||||
|
// mock entry to pass to binary service
|
||||||
|
entry := &entrytypes.Article{}
|
||||||
|
entry.SetID(post.Id)
|
||||||
|
|
||||||
|
fileData, err := os.ReadFile(path.Join(mediaDir, file))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
app.BinaryService.CreateEntryFile(file, fileData, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry model.Entry
|
||||||
|
|
||||||
|
switch post.Meta.Type {
|
||||||
|
case "article":
|
||||||
|
entry = &entrytypes.Article{}
|
||||||
|
entry.SetID(post.Id)
|
||||||
|
entry.SetPublishedAt(&post.Meta.Date)
|
||||||
|
entry.SetMetaData(&entrytypes.ArticleMetaData{
|
||||||
|
Title: post.Meta.Title,
|
||||||
|
Content: post.Content,
|
||||||
|
})
|
||||||
|
case "bookmark":
|
||||||
|
entry = &entrytypes.Bookmark{}
|
||||||
|
entry.SetID(post.Id)
|
||||||
|
entry.SetPublishedAt(&post.Meta.Date)
|
||||||
|
entry.SetMetaData(&entrytypes.BookmarkMetaData{
|
||||||
|
Url: post.Meta.Bookmark.Url,
|
||||||
|
Title: post.Meta.Bookmark.Text,
|
||||||
|
Content: post.Content,
|
||||||
|
})
|
||||||
|
case "reply":
|
||||||
|
entry = &entrytypes.Reply{}
|
||||||
|
entry.SetID(post.Id)
|
||||||
|
entry.SetPublishedAt(&post.Meta.Date)
|
||||||
|
entry.SetMetaData(&entrytypes.ReplyMetaData{
|
||||||
|
Url: post.Meta.Reply.Url,
|
||||||
|
Title: post.Meta.Reply.Text,
|
||||||
|
Content: post.Content,
|
||||||
|
})
|
||||||
|
case "photo":
|
||||||
|
entry = &entrytypes.Image{}
|
||||||
|
entry.SetID(post.Id)
|
||||||
|
entry.SetPublishedAt(&post.Meta.Date)
|
||||||
|
entry.SetMetaData(&entrytypes.ImageMetaData{
|
||||||
|
Title: post.Meta.Title,
|
||||||
|
Content: post.Content,
|
||||||
|
ImageId: post.Meta.PhotoPath,
|
||||||
|
})
|
||||||
|
case "note":
|
||||||
|
entry = &entrytypes.Note{}
|
||||||
|
entry.SetID(post.Id)
|
||||||
|
entry.SetPublishedAt(&post.Meta.Date)
|
||||||
|
entry.SetMetaData(&entrytypes.NoteMetaData{
|
||||||
|
Content: post.Content,
|
||||||
|
})
|
||||||
|
case "recipe":
|
||||||
|
entry = &entrytypes.Recipe{}
|
||||||
|
entry.SetID(post.Id)
|
||||||
|
entry.SetPublishedAt(&post.Meta.Date)
|
||||||
|
entry.SetMetaData(&entrytypes.RecipeMetaData{
|
||||||
|
Title: post.Meta.Title,
|
||||||
|
Yield: post.Meta.Recipe.Yield,
|
||||||
|
Duration: post.Meta.Recipe.Duration,
|
||||||
|
Ingredients: post.Meta.Recipe.Ingredients,
|
||||||
|
Content: post.Content,
|
||||||
|
})
|
||||||
|
case "page":
|
||||||
|
entry = &entrytypes.Page{}
|
||||||
|
entry.SetID(post.Id)
|
||||||
|
entry.SetPublishedAt(&post.Meta.Date)
|
||||||
|
entry.SetMetaData(&entrytypes.PageMetaData{
|
||||||
|
Title: post.Meta.Title,
|
||||||
|
Content: post.Content,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
panic("Unknown type")
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry != nil {
|
||||||
|
entry.SetAuthorId(author)
|
||||||
|
app.EntryService.Create(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
|
@ -1,38 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"h4kor/owl-blogs"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var domain string
|
|
||||||
var singleUser string
|
|
||||||
var unsafe bool
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(initCmd)
|
|
||||||
|
|
||||||
initCmd.PersistentFlags().StringVar(&domain, "domain", "http://localhost:8080", "Domain to use")
|
|
||||||
initCmd.PersistentFlags().StringVar(&singleUser, "single-user", "", "Use single user mode with given username")
|
|
||||||
initCmd.PersistentFlags().BoolVar(&unsafe, "unsafe", false, "Allow raw html")
|
|
||||||
}
|
|
||||||
|
|
||||||
var initCmd = &cobra.Command{
|
|
||||||
Use: "init",
|
|
||||||
Short: "Creates a new repository",
|
|
||||||
Long: `Creates a new repository`,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
_, err := owl.CreateRepository(repoPath, owl.RepoConfig{
|
|
||||||
Domain: domain,
|
|
||||||
SingleUser: singleUser,
|
|
||||||
AllowRawHtml: unsafe,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
println("Error creating repository: ", err.Error())
|
|
||||||
} else {
|
|
||||||
println("Repository created: ", repoPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
|
||||||
}
|
|
|
@ -3,11 +3,19 @@ package main
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"owl-blogs/app"
|
||||||
|
entrytypes "owl-blogs/entry_types"
|
||||||
|
"owl-blogs/infra"
|
||||||
|
"owl-blogs/interactions"
|
||||||
|
"owl-blogs/plugings"
|
||||||
|
"owl-blogs/render"
|
||||||
|
"owl-blogs/web"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var repoPath string
|
const DbPath = "owlblogs.db"
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "owl",
|
Use: "owl",
|
||||||
Short: "Owl Blogs is a not so static blog generator",
|
Short: "Owl Blogs is a not so static blog generator",
|
||||||
|
@ -20,10 +28,68 @@ func Execute() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func App(db infra.Database) *web.WebApp {
|
||||||
|
// Register Types
|
||||||
|
entryRegister := app.NewEntryTypeRegistry()
|
||||||
|
entryRegister.Register(&entrytypes.Image{})
|
||||||
|
entryRegister.Register(&entrytypes.Article{})
|
||||||
|
entryRegister.Register(&entrytypes.Page{})
|
||||||
|
entryRegister.Register(&entrytypes.Recipe{})
|
||||||
|
entryRegister.Register(&entrytypes.Note{})
|
||||||
|
entryRegister.Register(&entrytypes.Bookmark{})
|
||||||
|
entryRegister.Register(&entrytypes.Reply{})
|
||||||
|
|
||||||
rootCmd.PersistentFlags().StringVar(&repoPath, "repo", ".", "Path to the repository to use.")
|
interactionRegister := app.NewInteractionTypeRegistry()
|
||||||
rootCmd.PersistentFlags().StringVar(&user, "user", "", "Username. Required for some commands.")
|
interactionRegister.Register(&interactions.Webmention{})
|
||||||
|
interactionRegister.Register(&interactions.Like{})
|
||||||
|
interactionRegister.Register(&interactions.Repost{})
|
||||||
|
|
||||||
|
configRegister := app.NewConfigRegister()
|
||||||
|
|
||||||
|
// Create Repositories
|
||||||
|
entryRepo := infra.NewEntryRepository(db, entryRegister)
|
||||||
|
binRepo := infra.NewBinaryFileRepo(db)
|
||||||
|
authorRepo := infra.NewDefaultAuthorRepo(db)
|
||||||
|
configRepo := infra.NewConfigRepo(db)
|
||||||
|
interactionRepo := infra.NewInteractionRepo(db, interactionRegister)
|
||||||
|
followersRepo := infra.NewFollowerRepository(db)
|
||||||
|
|
||||||
|
// Create External Services
|
||||||
|
httpClient := &infra.OwlHttpClient{}
|
||||||
|
|
||||||
|
// busses
|
||||||
|
eventBus := app.NewEventBus()
|
||||||
|
|
||||||
|
// Create Services
|
||||||
|
siteConfigService := app.NewSiteConfigService(configRepo)
|
||||||
|
entryService := app.NewEntryService(entryRepo, siteConfigService, 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
|
||||||
|
|
||||||
|
// plugins
|
||||||
|
plugings.NewEcho(eventBus)
|
||||||
|
plugings.RegisterInstagram(
|
||||||
|
configRepo, configRegister, binaryService, eventBus,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create WebApp
|
||||||
|
return web.NewWebApp(
|
||||||
|
entryService, entryRegister, binaryService,
|
||||||
|
authorService, configRepo, configRegister,
|
||||||
|
siteConfigService, webmentionService, interactionRepo,
|
||||||
|
apService,
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"owl-blogs/test"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMediaWithSpace(t *testing.T) {
|
||||||
|
db := test.NewMockDb()
|
||||||
|
owlApp := App(db)
|
||||||
|
app := owlApp.FiberApp
|
||||||
|
|
||||||
|
_, err := owlApp.BinaryService.Create("name with space.jpg", []byte("111"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/media/name%20with%20space.jpg", nil)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
}
|
|
@ -1,50 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"h4kor/owl-blogs"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var postTitle string
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(newPostCmd)
|
|
||||||
newPostCmd.PersistentFlags().StringVar(&postTitle, "title", "", "Post title")
|
|
||||||
}
|
|
||||||
|
|
||||||
var newPostCmd = &cobra.Command{
|
|
||||||
Use: "new-post",
|
|
||||||
Short: "Creates a new post",
|
|
||||||
Long: `Creates a new post`,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
if user == "" {
|
|
||||||
println("Username is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if postTitle == "" {
|
|
||||||
println("Post title is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
repo, err := owl.OpenRepository(repoPath)
|
|
||||||
if err != nil {
|
|
||||||
println("Error opening repository: ", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := repo.GetUser(user)
|
|
||||||
if err != nil {
|
|
||||||
println("Error getting user: ", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = user.CreateNewPost(postTitle)
|
|
||||||
if err != nil {
|
|
||||||
println("Error creating post: ", err.Error())
|
|
||||||
} else {
|
|
||||||
println("Post created: ", postTitle)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
|
@ -1,38 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"h4kor/owl-blogs"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var user string
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(newUserCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var newUserCmd = &cobra.Command{
|
|
||||||
Use: "new-user",
|
|
||||||
Short: "Creates a new user",
|
|
||||||
Long: `Creates a new user`,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
if user == "" {
|
|
||||||
println("Username is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
repo, err := owl.OpenRepository(repoPath)
|
|
||||||
if err != nil {
|
|
||||||
println("Error opening repository: ", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = repo.CreateUser(user)
|
|
||||||
if err != nil {
|
|
||||||
println("Error creating user: ", err.Error())
|
|
||||||
} else {
|
|
||||||
println("User created: ", user)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/infra"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(resetPasswordCmd)
|
||||||
|
|
||||||
|
resetPasswordCmd.Flags().StringVarP(&user, "user", "u", "", "The user name")
|
||||||
|
resetPasswordCmd.MarkFlagRequired("user")
|
||||||
|
resetPasswordCmd.Flags().StringVarP(&password, "password", "p", "", "The new password")
|
||||||
|
resetPasswordCmd.MarkFlagRequired("password")
|
||||||
|
}
|
||||||
|
|
||||||
|
var resetPasswordCmd = &cobra.Command{
|
||||||
|
Use: "reset-password",
|
||||||
|
Short: "Resets the password of an author",
|
||||||
|
Long: `Resets the password of an author`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
db := infra.NewSqliteDB(DbPath)
|
||||||
|
App(db).AuthorService.Create(user, password)
|
||||||
|
},
|
||||||
|
}
|
|
@ -1,17 +1,13 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
web "h4kor/owl-blogs/cmd/owl/web"
|
"owl-blogs/infra"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var port int
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(webCmd)
|
rootCmd.AddCommand(webCmd)
|
||||||
|
|
||||||
webCmd.PersistentFlags().IntVar(&port, "port", 8080, "Port to use")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var webCmd = &cobra.Command{
|
var webCmd = &cobra.Command{
|
||||||
|
@ -19,6 +15,7 @@ var webCmd = &cobra.Command{
|
||||||
Short: "Start the web server",
|
Short: "Start the web server",
|
||||||
Long: `Start the web server`,
|
Long: `Start the web server`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
web.StartServer(repoPath, port)
|
db := infra.NewSqliteDB(DbPath)
|
||||||
|
App(db).Run()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,238 +0,0 @@
|
||||||
package web_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"h4kor/owl-blogs"
|
|
||||||
main "h4kor/owl-blogs/cmd/owl/web"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRedirectOnAliases(t *testing.T) {
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
content := "---\n"
|
|
||||||
content += "title: Test\n"
|
|
||||||
content += "aliases: \n"
|
|
||||||
content += " - /foo/bar\n"
|
|
||||||
content += " - /foo/baz\n"
|
|
||||||
content += "---\n"
|
|
||||||
content += "This is a test"
|
|
||||||
os.WriteFile(post.ContentFile(), []byte(content), 0644)
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", "/foo/bar", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.Router(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusMovedPermanently {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusMovedPermanently)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that Location header is set correctly
|
|
||||||
if rr.Header().Get("Location") != post.UrlPath() {
|
|
||||||
t.Errorf("Location header is not set correctly, expected: %v Got: %v",
|
|
||||||
post.UrlPath(),
|
|
||||||
rr.Header().Get("Location"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNoRedirectOnNonExistingAliases(t *testing.T) {
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
content := "---\n"
|
|
||||||
content += "title: Test\n"
|
|
||||||
content += "aliases: \n"
|
|
||||||
content += " - /foo/bar\n"
|
|
||||||
content += " - /foo/baz\n"
|
|
||||||
content += "---\n"
|
|
||||||
content += "This is a test"
|
|
||||||
os.WriteFile(post.ContentFile(), []byte(content), 0644)
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", "/foo/bar2", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.Router(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusNotFound {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNoRedirectIfValidPostUrl(t *testing.T) {
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
post2, _ := user.CreateNewPost("post-2")
|
|
||||||
|
|
||||||
content := "---\n"
|
|
||||||
content += "title: Test\n"
|
|
||||||
content += "aliases: \n"
|
|
||||||
content += " - " + post2.UrlPath() + "\n"
|
|
||||||
content += "---\n"
|
|
||||||
content += "This is a test"
|
|
||||||
os.WriteFile(post.ContentFile(), []byte(content), 0644)
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", post2.UrlPath(), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.Router(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRedirectIfInvalidPostUrl(t *testing.T) {
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
content := "---\n"
|
|
||||||
content += "title: Test\n"
|
|
||||||
content += "aliases: \n"
|
|
||||||
content += " - " + user.UrlPath() + "posts/not-a-real-post/" + "\n"
|
|
||||||
content += "---\n"
|
|
||||||
content += "This is a test"
|
|
||||||
os.WriteFile(post.ContentFile(), []byte(content), 0644)
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", user.UrlPath()+"posts/not-a-real-post/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.Router(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusMovedPermanently {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusMovedPermanently)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRedirectIfInvalidUserUrl(t *testing.T) {
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
content := "---\n"
|
|
||||||
content += "title: Test\n"
|
|
||||||
content += "aliases: \n"
|
|
||||||
content += " - /user/not-real/ \n"
|
|
||||||
content += "---\n"
|
|
||||||
content += "This is a test"
|
|
||||||
os.WriteFile(post.ContentFile(), []byte(content), 0644)
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", "/user/not-real/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.Router(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusMovedPermanently {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusMovedPermanently)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRedirectIfInvalidMediaUrl(t *testing.T) {
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
content := "---\n"
|
|
||||||
content += "title: Test\n"
|
|
||||||
content += "aliases: \n"
|
|
||||||
content += " - " + post.UrlMediaPath("not-real") + "\n"
|
|
||||||
content += "---\n"
|
|
||||||
content += "This is a test"
|
|
||||||
os.WriteFile(post.ContentFile(), []byte(content), 0644)
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", post.UrlMediaPath("not-real"), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.Router(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusMovedPermanently {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusMovedPermanently)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeepAliasInSingleUserMode(t *testing.T) {
|
|
||||||
repo := getTestRepo(owl.RepoConfig{SingleUser: "test-1"})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
content := "---\n"
|
|
||||||
content += "title: Create tileable textures with GIMP\n"
|
|
||||||
content += "author: h4kor\n"
|
|
||||||
content += "type: post\n"
|
|
||||||
content += "date: Tue, 13 Sep 2016 16:19:09 +0000\n"
|
|
||||||
content += "aliases:\n"
|
|
||||||
content += " - /2016/09/13/create-tileable-textures-with-gimp/\n"
|
|
||||||
content += "categories:\n"
|
|
||||||
content += " - GameDev\n"
|
|
||||||
content += "tags:\n"
|
|
||||||
content += " - gamedev\n"
|
|
||||||
content += " - textures\n"
|
|
||||||
content += "---\n"
|
|
||||||
content += "This is a test"
|
|
||||||
os.WriteFile(post.ContentFile(), []byte(content), 0644)
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", "/2016/09/13/create-tileable-textures-with-gimp/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.SingleUserRouter(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusMovedPermanently {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusMovedPermanently)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,228 +0,0 @@
|
||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"h4kor/owl-blogs"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
|
||||||
)
|
|
||||||
|
|
||||||
func getUserFromRepo(repo *owl.Repository, ps httprouter.Params) (owl.User, error) {
|
|
||||||
if config, _ := repo.Config(); config.SingleUser != "" {
|
|
||||||
return repo.GetUser(config.SingleUser)
|
|
||||||
}
|
|
||||||
userName := ps.ByName("user")
|
|
||||||
user, err := repo.GetUser(userName)
|
|
||||||
if err != nil {
|
|
||||||
return owl.User{}, err
|
|
||||||
}
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func repoIndexHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
|
||||||
html, err := owl.RenderUserList(*repo)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
println("Error rendering index: ", err.Error())
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte("Internal server error"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
println("Rendering index")
|
|
||||||
w.Write([]byte(html))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func userIndexHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
|
||||||
user, err := getUserFromRepo(repo, ps)
|
|
||||||
if err != nil {
|
|
||||||
println("Error getting user: ", err.Error())
|
|
||||||
notFoundHandler(repo)(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
html, err := owl.RenderIndexPage(user)
|
|
||||||
if err != nil {
|
|
||||||
println("Error rendering index page: ", err.Error())
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte("Internal server error"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
println("Rendering index page for user", user.Name())
|
|
||||||
w.Write([]byte(html))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func userWebmentionHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
|
||||||
user, err := getUserFromRepo(repo, ps)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte("User not found"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = r.ParseForm()
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte("Unable to parse form data"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params := r.PostForm
|
|
||||||
target := params["target"]
|
|
||||||
source := params["source"]
|
|
||||||
if len(target) == 0 {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte("No target provided"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(source) == 0 {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte("No source provided"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(target[0]) < 7 || (target[0][:7] != "http://" && target[0][:8] != "https://") {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte("Not a valid target"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(source[0]) < 7 || (source[0][:7] != "http://" && source[0][:8] != "https://") {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte("Not a valid source"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if source[0] == target[0] {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte("target and source are equal"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(target[0], "/")
|
|
||||||
if len(parts) < 2 {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte("Not found"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
postId := parts[len(parts)-2]
|
|
||||||
post, err := user.GetPost(postId)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte("Post not found"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = post.AddIncomingWebmention(source[0])
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte("Unable to process webmention"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusAccepted)
|
|
||||||
w.Write([]byte(""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func userRSSHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
|
||||||
user, err := getUserFromRepo(repo, ps)
|
|
||||||
if err != nil {
|
|
||||||
println("Error getting user: ", err.Error())
|
|
||||||
notFoundHandler(repo)(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
xml, err := owl.RenderRSSFeed(user)
|
|
||||||
if err != nil {
|
|
||||||
println("Error rendering index page: ", err.Error())
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte("Internal server error"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
println("Rendering index page for user", user.Name())
|
|
||||||
w.Header().Set("Content-Type", "application/rss+xml")
|
|
||||||
w.Write([]byte(xml))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func postHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
|
||||||
postId := ps.ByName("post")
|
|
||||||
|
|
||||||
user, err := getUserFromRepo(repo, ps)
|
|
||||||
if err != nil {
|
|
||||||
println("Error getting user: ", err.Error())
|
|
||||||
notFoundHandler(repo)(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
post, err := user.GetPost(postId)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
println("Error getting post: ", err.Error())
|
|
||||||
notFoundHandler(repo)(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
meta := post.Meta()
|
|
||||||
if meta.Draft {
|
|
||||||
println("Post is a draft")
|
|
||||||
notFoundHandler(repo)(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
html, err := owl.RenderPost(&post)
|
|
||||||
if err != nil {
|
|
||||||
println("Error rendering post: ", err.Error())
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte("Internal server error"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
println("Rendering post", postId)
|
|
||||||
w.Write([]byte(html))
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func postMediaHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
|
||||||
postId := ps.ByName("post")
|
|
||||||
filepath := ps.ByName("filepath")
|
|
||||||
|
|
||||||
user, err := getUserFromRepo(repo, ps)
|
|
||||||
if err != nil {
|
|
||||||
println("Error getting user: ", err.Error())
|
|
||||||
notFoundHandler(repo)(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
post, err := user.GetPost(postId)
|
|
||||||
if err != nil {
|
|
||||||
println("Error getting post: ", err.Error())
|
|
||||||
notFoundHandler(repo)(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
filepath = path.Join(post.MediaDir(), filepath)
|
|
||||||
if _, err := os.Stat(filepath); err != nil {
|
|
||||||
println("Error getting file: ", err.Error())
|
|
||||||
notFoundHandler(repo)(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.ServeFile(w, r, filepath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func notFoundHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request) {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
path := r.URL.Path
|
|
||||||
aliases, _ := repo.PostAliases()
|
|
||||||
if _, ok := aliases[path]; ok {
|
|
||||||
http.Redirect(w, r, aliases[path].UrlPath(), http.StatusMovedPermanently)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
w.Write([]byte("Not found"))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,146 +0,0 @@
|
||||||
package web_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"h4kor/owl-blogs"
|
|
||||||
main "h4kor/owl-blogs/cmd/owl/web"
|
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func randomName() string {
|
|
||||||
rand.Seed(time.Now().UnixNano())
|
|
||||||
var letters = []rune("abcdefghijklmnopqrstuvwxyz")
|
|
||||||
b := make([]rune, 8)
|
|
||||||
for i := range b {
|
|
||||||
b[i] = letters[rand.Intn(len(letters))]
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRepoName() string {
|
|
||||||
return "/tmp/" + randomName()
|
|
||||||
}
|
|
||||||
|
|
||||||
func getTestRepo(config owl.RepoConfig) owl.Repository {
|
|
||||||
repo, _ := owl.CreateRepository(testRepoName(), config)
|
|
||||||
return repo
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMultiUserRepoIndexHandler(t *testing.T) {
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
repo.CreateUser("user_1")
|
|
||||||
repo.CreateUser("user_2")
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.Router(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check the response body contains names of users
|
|
||||||
if !strings.Contains(rr.Body.String(), "user_1") {
|
|
||||||
t.Error("user_1 not listed on index page. Got: ")
|
|
||||||
t.Error(rr.Body.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(rr.Body.String(), "user_2") {
|
|
||||||
t.Error("user_2 not listed on index page. Got: ")
|
|
||||||
t.Error(rr.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMultiUserUserIndexHandler(t *testing.T) {
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", user.UrlPath(), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.Router(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check the response body contains names of users
|
|
||||||
if !strings.Contains(rr.Body.String(), "post-1") {
|
|
||||||
t.Error("post-1 not listed on index page. Got: ")
|
|
||||||
t.Error(rr.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMultiUserPostHandler(t *testing.T) {
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", post.UrlPath(), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.Router(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMultiUserPostMediaHandler(t *testing.T) {
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
// Create test media file
|
|
||||||
path := path.Join(post.MediaDir(), "data.txt")
|
|
||||||
err := os.WriteFile(path, []byte("test"), 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", post.UrlMediaPath("data.txt"), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.Router(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check the response body contains data of media file
|
|
||||||
if !(rr.Body.String() == "test") {
|
|
||||||
t.Error("Got wrong media file content. Expected 'test' Got: ")
|
|
||||||
t.Error(rr.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
package web_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"h4kor/owl-blogs"
|
|
||||||
main "h4kor/owl-blogs/cmd/owl/web"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPostHandlerReturns404OnDrafts(t *testing.T) {
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
content := "---\n"
|
|
||||||
content += "title: test\n"
|
|
||||||
content += "draft: true\n"
|
|
||||||
content += "---\n"
|
|
||||||
content += "\n"
|
|
||||||
content += "Write your post here.\n"
|
|
||||||
os.WriteFile(post.ContentFile(), []byte(content), 0644)
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", post.UrlPath(), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.Router(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusNotFound {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusNotFound)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,43 +0,0 @@
|
||||||
package web_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"h4kor/owl-blogs"
|
|
||||||
main "h4kor/owl-blogs/cmd/owl/web"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMultiUserUserRssIndexHandler(t *testing.T) {
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", user.UrlPath()+"index.xml", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.Router(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check the response Content-Type is what we expect.
|
|
||||||
if !strings.Contains(rr.Header().Get("Content-Type"), "application/rss+xml") {
|
|
||||||
t.Errorf("handler returned wrong Content-Type: got %v want %v",
|
|
||||||
rr.Header().Get("Content-Type"), "application/rss+xml")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check the response body contains names of users
|
|
||||||
if !strings.Contains(rr.Body.String(), "post-1") {
|
|
||||||
t.Error("post-1 not listed on index page. Got: ")
|
|
||||||
t.Error(rr.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,57 +0,0 @@
|
||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"h4kor/owl-blogs"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Router(repo *owl.Repository) http.Handler {
|
|
||||||
router := httprouter.New()
|
|
||||||
router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir()))
|
|
||||||
router.GET("/", repoIndexHandler(repo))
|
|
||||||
router.GET("/user/:user/", userIndexHandler(repo))
|
|
||||||
router.POST("/user/:user/webmention/", userWebmentionHandler(repo))
|
|
||||||
router.GET("/user/:user/index.xml", userRSSHandler(repo))
|
|
||||||
router.GET("/user/:user/posts/:post/", postHandler(repo))
|
|
||||||
router.GET("/user/:user/posts/:post/media/*filepath", postMediaHandler(repo))
|
|
||||||
router.NotFound = http.HandlerFunc(notFoundHandler(repo))
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
func SingleUserRouter(repo *owl.Repository) http.Handler {
|
|
||||||
router := httprouter.New()
|
|
||||||
router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir()))
|
|
||||||
router.GET("/", userIndexHandler(repo))
|
|
||||||
router.POST("/webmention/", userWebmentionHandler(repo))
|
|
||||||
router.GET("/index.xml", userRSSHandler(repo))
|
|
||||||
router.GET("/posts/:post/", postHandler(repo))
|
|
||||||
router.GET("/posts/:post/media/*filepath", postMediaHandler(repo))
|
|
||||||
router.NotFound = http.HandlerFunc(notFoundHandler(repo))
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
func StartServer(repoPath string, port int) {
|
|
||||||
var repo owl.Repository
|
|
||||||
var err error
|
|
||||||
repo, err = owl.OpenRepository(repoPath)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
println("Error opening repository: ", err.Error())
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
var router http.Handler
|
|
||||||
if config, _ := repo.Config(); config.SingleUser != "" {
|
|
||||||
router = SingleUserRouter(&repo)
|
|
||||||
} else {
|
|
||||||
router = Router(&repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
println("Listening on port", port)
|
|
||||||
http.ListenAndServe(":"+strconv.Itoa(port), router)
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,132 +0,0 @@
|
||||||
package web_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
owl "h4kor/owl-blogs"
|
|
||||||
main "h4kor/owl-blogs/cmd/owl/web"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func getSingleUserTestRepo() (owl.Repository, owl.User) {
|
|
||||||
repo, _ := owl.CreateRepository(testRepoName(), owl.RepoConfig{SingleUser: "test-1"})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
return repo, user
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSingleUserUserIndexHandler(t *testing.T) {
|
|
||||||
repo, user := getSingleUserTestRepo()
|
|
||||||
user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", user.UrlPath(), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.SingleUserRouter(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check the response body contains names of users
|
|
||||||
if !strings.Contains(rr.Body.String(), "post-1") {
|
|
||||||
t.Error("post-1 not listed on index page. Got: ")
|
|
||||||
t.Error(rr.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSingleUserPostHandler(t *testing.T) {
|
|
||||||
repo, user := getSingleUserTestRepo()
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", post.UrlPath(), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.SingleUserRouter(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSingleUserPostMediaHandler(t *testing.T) {
|
|
||||||
repo, user := getSingleUserTestRepo()
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
// Create test media file
|
|
||||||
path := path.Join(post.MediaDir(), "data.txt")
|
|
||||||
err := os.WriteFile(path, []byte("test"), 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", post.UrlMediaPath("data.txt"), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.SingleUserRouter(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check the response body contains data of media file
|
|
||||||
if !(rr.Body.String() == "test") {
|
|
||||||
t.Error("Got wrong media file content. Expected 'test' Got: ")
|
|
||||||
t.Error(rr.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHasNoDraftsInList(t *testing.T) {
|
|
||||||
repo, user := getSingleUserTestRepo()
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
content := ""
|
|
||||||
content += "---\n"
|
|
||||||
content += "title: Articles September 2019\n"
|
|
||||||
content += "author: h4kor\n"
|
|
||||||
content += "type: post\n"
|
|
||||||
content += "date: -001-11-30T00:00:00+00:00\n"
|
|
||||||
content += "draft: true\n"
|
|
||||||
content += "url: /?p=426\n"
|
|
||||||
content += "categories:\n"
|
|
||||||
content += " - Uncategorised\n"
|
|
||||||
content += "\n"
|
|
||||||
content += "---\n"
|
|
||||||
content += "<https://nesslabs.com/time-anxiety>\n"
|
|
||||||
|
|
||||||
os.WriteFile(post.ContentFile(), []byte(content), 0644)
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("GET", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.SingleUserRouter(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check if title is in the response body
|
|
||||||
if strings.Contains(rr.Body.String(), "Articles September 2019") {
|
|
||||||
t.Error("Articles September 2019 listed on index page. Got: ")
|
|
||||||
t.Error(rr.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,161 +0,0 @@
|
||||||
package web_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"h4kor/owl-blogs"
|
|
||||||
main "h4kor/owl-blogs/cmd/owl/web"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupWebmentionTest(repo owl.Repository, user owl.User, target string, source string) (*httptest.ResponseRecorder, error) {
|
|
||||||
|
|
||||||
data := url.Values{}
|
|
||||||
data.Set("target", target)
|
|
||||||
data.Set("source", source)
|
|
||||||
|
|
||||||
// Create Request and Response
|
|
||||||
req, err := http.NewRequest("POST", user.UrlPath()+"webmention/", strings.NewReader(data.Encode()))
|
|
||||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Add("Content-Length", strconv.Itoa(len(data.Encode())))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router := main.Router(&repo)
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
return rr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertStatus(t *testing.T, rr *httptest.ResponseRecorder, expStatus int) {
|
|
||||||
if status := rr.Code; status != expStatus {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, expStatus)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebmentionHandleAccepts(t *testing.T) {
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
target := post.FullUrl()
|
|
||||||
source := "https://example.com"
|
|
||||||
|
|
||||||
rr, err := setupWebmentionTest(repo, user, target, source)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertStatus(t, rr, http.StatusAccepted)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebmentionWrittenToPost(t *testing.T) {
|
|
||||||
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
target := post.FullUrl()
|
|
||||||
source := "https://example.com"
|
|
||||||
|
|
||||||
rr, err := setupWebmentionTest(repo, user, target, source)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
assertStatus(t, rr, http.StatusAccepted)
|
|
||||||
|
|
||||||
if len(post.IncomingWebmentions()) != 1 {
|
|
||||||
t.Errorf("no webmention written to post")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// https://www.w3.org/TR/webmention/#h-request-verification
|
|
||||||
//
|
|
||||||
|
|
||||||
// The receiver MUST check that source and target are valid URLs [URL]
|
|
||||||
// and are of schemes that are supported by the receiver.
|
|
||||||
// (Most commonly this means checking that the source and target schemes are http or https).
|
|
||||||
func TestWebmentionSourceValidation(t *testing.T) {
|
|
||||||
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
target := post.FullUrl()
|
|
||||||
source := "ftp://example.com"
|
|
||||||
|
|
||||||
rr, err := setupWebmentionTest(repo, user, target, source)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertStatus(t, rr, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebmentionTargetValidation(t *testing.T) {
|
|
||||||
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
target := "ftp://example.com"
|
|
||||||
source := post.FullUrl()
|
|
||||||
|
|
||||||
rr, err := setupWebmentionTest(repo, user, target, source)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertStatus(t, rr, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The receiver MUST reject the request if the source URL is the same as the target URL.
|
|
||||||
|
|
||||||
func TestWebmentionSameTargetAndSource(t *testing.T) {
|
|
||||||
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
target := post.FullUrl()
|
|
||||||
source := post.FullUrl()
|
|
||||||
|
|
||||||
rr, err := setupWebmentionTest(repo, user, target, source)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertStatus(t, rr, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The receiver SHOULD check that target is a valid resource for which it can accept Webmentions.
|
|
||||||
// This check SHOULD happen synchronously to reject invalid Webmentions before more in-depth verification begins.
|
|
||||||
// What a "valid resource" means is up to the receiver.
|
|
||||||
func TestValidationOfTarget(t *testing.T) {
|
|
||||||
repo := getTestRepo(owl.RepoConfig{})
|
|
||||||
user, _ := repo.CreateUser("test-1")
|
|
||||||
post, _ := user.CreateNewPost("post-1")
|
|
||||||
|
|
||||||
target := post.FullUrl()
|
|
||||||
target = target[:len(target)-1] + "invalid"
|
|
||||||
source := post.FullUrl()
|
|
||||||
|
|
||||||
rr, err := setupWebmentionTest(repo, user, target, source)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertStatus(t, rr, http.StatusBadRequest)
|
|
||||||
}
|
|
|
@ -1,70 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"h4kor/owl-blogs"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(webmentionCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var webmentionCmd = &cobra.Command{
|
|
||||||
Use: "webmention",
|
|
||||||
Short: "Send webmentions for posts, optionally for a specific user",
|
|
||||||
Long: `Send webmentions for posts, optionally for a specific user`,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
repo, err := owl.OpenRepository(repoPath)
|
|
||||||
if err != nil {
|
|
||||||
println("Error opening repository: ", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var users []owl.User
|
|
||||||
if user == "" {
|
|
||||||
// send webmentions for all users
|
|
||||||
users, err = repo.Users()
|
|
||||||
if err != nil {
|
|
||||||
println("Error getting users: ", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// send webmentions for a specific user
|
|
||||||
user, err := repo.GetUser(user)
|
|
||||||
users = append(users, user)
|
|
||||||
if err != nil {
|
|
||||||
println("Error getting user: ", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, user := range users {
|
|
||||||
posts, err := user.Posts()
|
|
||||||
if err != nil {
|
|
||||||
println("Error getting posts: ", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, post := range posts {
|
|
||||||
println("Webmentions for post: ", post.Title())
|
|
||||||
|
|
||||||
err := post.ScanForLinks()
|
|
||||||
if err != nil {
|
|
||||||
println("Error scanning post for links: ", err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
webmentions := post.OutgoingWebmentions()
|
|
||||||
println("Found ", len(webmentions), " links")
|
|
||||||
for _, webmention := range webmentions {
|
|
||||||
err = post.SendWebmention(webmention)
|
|
||||||
if err != nil {
|
|
||||||
println("Error sending webmentions: ", err.Error())
|
|
||||||
} else {
|
|
||||||
println("Webmention sent to ", webmention.Target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
const (
|
||||||
|
SITE_CONFIG = "site_config"
|
||||||
|
ACT_PUB_CONF_NAME = "activity_pub"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config interface {
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnvConfig struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvOrPanic(key string) string {
|
||||||
|
value, set := os.LookupEnv(key)
|
||||||
|
if !set {
|
||||||
|
panic("Environment variable " + key + " is not set")
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfig() Config {
|
||||||
|
return &EnvConfig{}
|
||||||
|
}
|
|
@ -1,40 +0,0 @@
|
||||||
package owl
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
func dirExists(path string) bool {
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// lists all files/dirs in a directory, not recursive
|
|
||||||
func listDir(path string) []string {
|
|
||||||
dir, _ := os.Open(path)
|
|
||||||
defer dir.Close()
|
|
||||||
files, _ := dir.Readdirnames(-1)
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
func fileExists(path string) bool {
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// recursive list of all files in a directory
|
|
||||||
func walkDir(path string) []string {
|
|
||||||
files := make([]string, 0)
|
|
||||||
filepath.Walk(path, func(subPath string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if info.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
files = append(files, subPath[len(path)+1:])
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return files
|
|
||||||
}
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
type Author struct {
|
||||||
|
Name string
|
||||||
|
PasswordHash string
|
||||||
|
FullUrl string
|
||||||
|
AvatarUrl string
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BinaryFile struct {
|
||||||
|
Id string
|
||||||
|
Name string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BinaryFile) Mime() string {
|
||||||
|
parts := strings.Split(b.Name, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
t := mime.TypeByExtension("." + parts[len(parts)-1])
|
||||||
|
if t == "" {
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
package model_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMimeType(t *testing.T) {
|
||||||
|
bin := model.BinaryFile{Name: "test.jpg"}
|
||||||
|
require.Equal(t, "image/jpeg", bin.Mime())
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
type BinaryStorageInterface interface {
|
||||||
|
Create(name string, file []byte) (*BinaryFile, error)
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EntryContent string
|
||||||
|
|
||||||
|
type Entry interface {
|
||||||
|
ID() string
|
||||||
|
Content() EntryContent
|
||||||
|
PublishedAt() *time.Time
|
||||||
|
AuthorId() string
|
||||||
|
MetaData() EntryMetaData
|
||||||
|
|
||||||
|
// Optional: can return empty string
|
||||||
|
Title() string
|
||||||
|
ImageUrl() string
|
||||||
|
|
||||||
|
SetID(id string)
|
||||||
|
SetPublishedAt(publishedAt *time.Time)
|
||||||
|
SetMetaData(metaData EntryMetaData)
|
||||||
|
SetAuthorId(authorId string)
|
||||||
|
|
||||||
|
FullUrl(cfg SiteConfig) string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntryMetaData interface {
|
||||||
|
Formable
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntryBase struct {
|
||||||
|
id string
|
||||||
|
publishedAt *time.Time
|
||||||
|
authorId string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntryBase) ID() string {
|
||||||
|
return e.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntryBase) PublishedAt() *time.Time {
|
||||||
|
return e.publishedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntryBase) ImageUrl() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntryBase) SetID(id string) {
|
||||||
|
e.id = id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntryBase) SetPublishedAt(publishedAt *time.Time) {
|
||||||
|
e.publishedAt = publishedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntryBase) AuthorId() string {
|
||||||
|
return e.authorId
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "mime/multipart"
|
||||||
|
|
||||||
|
type Formable interface {
|
||||||
|
Form(binSvc BinaryStorageInterface) string
|
||||||
|
ParseFormData(data HttpFormData, binSvc BinaryStorageInterface) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type HttpFormData interface {
|
||||||
|
// FormFile returns the first file by key from a MultipartForm.
|
||||||
|
FormFile(key string) (*multipart.FileHeader, error)
|
||||||
|
// FormValue returns the first value by key from a MultipartForm.
|
||||||
|
// Search is performed in QueryArgs, PostArgs, MultipartForm and FormFile in this particular order.
|
||||||
|
// Defaults to the empty string "" if the form value doesn't exist.
|
||||||
|
// If a default value is given, it will return that value if the form value does not exist.
|
||||||
|
// Returned value is only valid within the handler. Do not store any references.
|
||||||
|
// Make copies or use the Immutable setting instead.
|
||||||
|
FormValue(key string, defaultValue ...string) string
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type InteractionContent string
|
||||||
|
|
||||||
|
// Interaction is a generic interface for all interactions with entries
|
||||||
|
// These interactions can be:
|
||||||
|
// - Webmention, Pingback, Trackback
|
||||||
|
// - Likes, Comments on third party sites
|
||||||
|
// - Comments on the site itself
|
||||||
|
type Interaction interface {
|
||||||
|
ID() string
|
||||||
|
EntryID() string
|
||||||
|
Content() InteractionContent
|
||||||
|
CreatedAt() time.Time
|
||||||
|
MetaData() interface{}
|
||||||
|
|
||||||
|
SetID(id string)
|
||||||
|
SetEntryID(entryID string)
|
||||||
|
SetCreatedAt(createdAt time.Time)
|
||||||
|
SetMetaData(metaData interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type InteractionBase struct {
|
||||||
|
id string
|
||||||
|
entryID string
|
||||||
|
createdAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *InteractionBase) ID() string {
|
||||||
|
return i.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *InteractionBase) EntryID() string {
|
||||||
|
return i.entryID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *InteractionBase) CreatedAt() time.Time {
|
||||||
|
return i.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *InteractionBase) SetID(id string) {
|
||||||
|
i.id = id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *InteractionBase) SetEntryID(entryID string) {
|
||||||
|
i.entryID = entryID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *InteractionBase) SetCreatedAt(createdAt time.Time) {
|
||||||
|
i.createdAt = createdAt
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
type MeLinks struct {
|
||||||
|
Name string
|
||||||
|
Url string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntryList struct {
|
||||||
|
Id string
|
||||||
|
Title string
|
||||||
|
Include []string
|
||||||
|
ListType string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MenuItem struct {
|
||||||
|
Title string
|
||||||
|
List string
|
||||||
|
Url string
|
||||||
|
Post string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SiteConfig struct {
|
||||||
|
Title string
|
||||||
|
SubTitle string
|
||||||
|
PrimaryColor string
|
||||||
|
AuthorName string
|
||||||
|
Me []MeLinks
|
||||||
|
Lists []EntryList
|
||||||
|
PrimaryListInclude []string
|
||||||
|
HeaderMenu []MenuItem
|
||||||
|
FooterMenu []MenuItem
|
||||||
|
Secret string
|
||||||
|
AvatarUrl string
|
||||||
|
FullUrl string
|
||||||
|
HtmlHeadExtra string
|
||||||
|
FooterExtra string
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
type SiteConfigInterface interface {
|
||||||
|
GetSiteConfig() (SiteConfig, error)
|
||||||
|
UpdateSiteConfig(cfg SiteConfig) error
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
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"]
|
|
@ -0,0 +1,12 @@
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ../
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
command: web
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
mock_masto:
|
||||||
|
build: mock_masto
|
||||||
|
ports:
|
||||||
|
- 8000:8000
|
|
@ -0,0 +1,24 @@
|
||||||
|
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
|
|
@ -0,0 +1,6 @@
|
||||||
|
FROM python:3.11
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
|
CMD [ "python", "main.py" ]
|
|
@ -0,0 +1,214 @@
|
||||||
|
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")
|
|
@ -0,0 +1 @@
|
||||||
|
Flask==3.0.3
|
|
@ -0,0 +1,17 @@
|
||||||
|
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
|
|
@ -0,0 +1,102 @@
|
||||||
|
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}"
|
|
@ -0,0 +1,88 @@
|
||||||
|
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
|
|
@ -0,0 +1,27 @@
|
||||||
|
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
|
6
embed.go
6
embed.go
|
@ -1,6 +0,0 @@
|
||||||
package owl
|
|
||||||
|
|
||||||
import "embed"
|
|
||||||
|
|
||||||
//go:embed embed/*
|
|
||||||
var embed_files embed.FS
|
|
|
@ -1,54 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{{ .Title }} - {{ .UserConfig.Title }}</title>
|
|
||||||
<link rel="stylesheet" href="/static/pico.min.css">
|
|
||||||
<link rel="webmention" href="{{ .User.WebmentionUrl }}">
|
|
||||||
<style>
|
|
||||||
header {
|
|
||||||
background-color: {{.UserConfig.HeaderColor}};
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
border-top: dashed 2px;
|
|
||||||
border-color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
hgroup h2 a { color: inherit; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav class="container">
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<hgroup>
|
|
||||||
<h2><a href="{{ .User.UrlPath }}">{{ .UserConfig.Title }}</a></h2>
|
|
||||||
<h3>{{ .UserConfig.SubTitle }}</h3>
|
|
||||||
</hgroup>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main class="container">
|
|
||||||
{{ .Content }}
|
|
||||||
</main>
|
|
||||||
<footer class="container">
|
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
{{ if .UserConfig.TwitterHandle}}
|
|
||||||
<li><a href="https://twitter.com/{{.UserConfig.TwitterHandle}}" rel="me">@{{.UserConfig.TwitterHandle}} on Twitter</a></li>
|
|
||||||
{{ end }}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</footer>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<ul>
|
|
||||||
{{ range .UserLinks }}
|
|
||||||
<li><a href="{{.Href}}">{{.Text}}</a></li>
|
|
||||||
{{ end }}
|
|
||||||
</ul>
|
|
|
@ -1,16 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{{ .Title }}</title>
|
|
||||||
<link rel="stylesheet" href="/static/pico.min.css">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
{{ .Content }}
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
File diff suppressed because one or more lines are too long
|
@ -1,16 +0,0 @@
|
||||||
<div class="h-feed">
|
|
||||||
{{range .}}
|
|
||||||
<div class="h-entry">
|
|
||||||
<hgroup>
|
|
||||||
<h3><a class="u-url" href="{{.UrlPath}}">{{.Title}}</a></h3>
|
|
||||||
<small>
|
|
||||||
Published:
|
|
||||||
<time class="dt-published" datetime="{{.Meta.Date}}">
|
|
||||||
{{.Meta.Date}}
|
|
||||||
</time>
|
|
||||||
</small>
|
|
||||||
</hgroup>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
|
@ -1,37 +0,0 @@
|
||||||
<div class="h-entry">
|
|
||||||
<hgroup>
|
|
||||||
<h1 class="p-name">{{.Title}}</h1>
|
|
||||||
<small>
|
|
||||||
Published:
|
|
||||||
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
|
|
||||||
{{.Post.Meta.Date}}
|
|
||||||
</time>
|
|
||||||
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
|
|
||||||
</small>
|
|
||||||
</hgroup>
|
|
||||||
<hr>
|
|
||||||
<br>
|
|
||||||
<div class="e-content">
|
|
||||||
{{.Content}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
{{if .Post.ApprovedWebmentions}}
|
|
||||||
<h3>
|
|
||||||
Webmentions
|
|
||||||
</h3>
|
|
||||||
<ul>
|
|
||||||
{{range .Post.ApprovedWebmentions}}
|
|
||||||
<li>
|
|
||||||
<a href="{{.Source}}">
|
|
||||||
{{if .Title}}
|
|
||||||
{{.Title}}
|
|
||||||
{{else}}
|
|
||||||
{{.Source}}
|
|
||||||
{{end}}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
|
@ -1,9 +0,0 @@
|
||||||
{{range .}}
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="{{ .UrlPath }}">
|
|
||||||
{{ .Name }}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{{end}}
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
package entrytypes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"owl-blogs/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Article struct {
|
||||||
|
model.EntryBase
|
||||||
|
meta ArticleMetaData
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArticleMetaData struct {
|
||||||
|
Title string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form implements model.EntryMetaData.
|
||||||
|
func (meta *ArticleMetaData) Form(binSvc model.BinaryStorageInterface) string {
|
||||||
|
f, _ := render.RenderTemplateToString("forms/Article", meta)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFormData implements model.EntryMetaData.
|
||||||
|
func (meta *ArticleMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
|
||||||
|
meta.Title = data.FormValue("title")
|
||||||
|
meta.Content = data.FormValue("content")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Article) Title() string {
|
||||||
|
return e.meta.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Article) Content() model.EntryContent {
|
||||||
|
str, err := render.RenderTemplateToString("entry/Article", e)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
return model.EntryContent(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Article) MetaData() model.EntryMetaData {
|
||||||
|
return &e.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Article) SetMetaData(metaData model.EntryMetaData) {
|
||||||
|
e.meta = *metaData.(*ArticleMetaData)
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
package entrytypes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"owl-blogs/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Bookmark struct {
|
||||||
|
model.EntryBase
|
||||||
|
meta BookmarkMetaData
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookmarkMetaData struct {
|
||||||
|
Title string
|
||||||
|
Url string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form implements model.EntryMetaData.
|
||||||
|
func (meta *BookmarkMetaData) Form(binSvc model.BinaryStorageInterface) string {
|
||||||
|
f, _ := render.RenderTemplateToString("forms/Bookmark", meta)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFormData implements model.EntryMetaData.
|
||||||
|
func (meta *BookmarkMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
|
||||||
|
meta.Title = data.FormValue("title")
|
||||||
|
meta.Url = data.FormValue("url")
|
||||||
|
meta.Content = data.FormValue("content")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Bookmark) Title() string {
|
||||||
|
return e.meta.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Bookmark) Content() model.EntryContent {
|
||||||
|
str, err := render.RenderTemplateToString("entry/Bookmark", e)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
return model.EntryContent(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Bookmark) MetaData() model.EntryMetaData {
|
||||||
|
return &e.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Bookmark) SetMetaData(metaData model.EntryMetaData) {
|
||||||
|
e.meta = *metaData.(*BookmarkMetaData)
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
package entrytypes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"owl-blogs/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Image struct {
|
||||||
|
model.EntryBase
|
||||||
|
meta ImageMetaData
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageMetaData struct {
|
||||||
|
ImageId string
|
||||||
|
Title string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form implements model.EntryMetaData.
|
||||||
|
func (meta *ImageMetaData) Form(binSvc model.BinaryStorageInterface) string {
|
||||||
|
f, _ := render.RenderTemplateToString("forms/Image", meta)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFormData implements model.EntryMetaData.
|
||||||
|
func (meta *ImageMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
|
||||||
|
file, err := data.FormFile("image")
|
||||||
|
var imgId = meta.ImageId
|
||||||
|
if err != nil && imgId == "" {
|
||||||
|
return err
|
||||||
|
} else if err == nil {
|
||||||
|
fileData, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer fileData.Close()
|
||||||
|
|
||||||
|
fileBytes := make([]byte, file.Size)
|
||||||
|
_, err = fileData.Read(fileBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bin, err := binSvc.Create(file.Filename, fileBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
imgId = bin.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.ImageId = imgId
|
||||||
|
meta.Title = data.FormValue("title")
|
||||||
|
meta.Content = data.FormValue("content")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Image) Title() string {
|
||||||
|
return e.meta.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Image) ImageUrl() string {
|
||||||
|
return "/media/" + e.meta.ImageId
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Image) Content() model.EntryContent {
|
||||||
|
str, err := render.RenderTemplateToString("entry/Image", e)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
return model.EntryContent(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Image) MetaData() model.EntryMetaData {
|
||||||
|
return &e.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Image) SetMetaData(metaData model.EntryMetaData) {
|
||||||
|
e.meta = *metaData.(*ImageMetaData)
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
package entrytypes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"owl-blogs/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Note struct {
|
||||||
|
model.EntryBase
|
||||||
|
meta NoteMetaData
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoteMetaData struct {
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form implements model.EntryMetaData.
|
||||||
|
func (meta *NoteMetaData) Form(binSvc model.BinaryStorageInterface) string {
|
||||||
|
f, _ := render.RenderTemplateToString("forms/Note", meta)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFormData implements model.EntryMetaData.
|
||||||
|
func (meta *NoteMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
|
||||||
|
meta.Content = data.FormValue("content")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Note) Title() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Note) Content() model.EntryContent {
|
||||||
|
str, err := render.RenderTemplateToString("entry/Note", e)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
return model.EntryContent(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Note) MetaData() model.EntryMetaData {
|
||||||
|
return &e.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Note) SetMetaData(metaData model.EntryMetaData) {
|
||||||
|
e.meta = *metaData.(*NoteMetaData)
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
package entrytypes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"owl-blogs/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Page struct {
|
||||||
|
model.EntryBase
|
||||||
|
meta PageMetaData
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageMetaData struct {
|
||||||
|
Title string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form implements model.EntryMetaData.
|
||||||
|
func (meta *PageMetaData) Form(binSvc model.BinaryStorageInterface) string {
|
||||||
|
f, _ := render.RenderTemplateToString("forms/Page", meta)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFormData implements model.EntryMetaData.
|
||||||
|
func (meta *PageMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
|
||||||
|
meta.Title = data.FormValue("title")
|
||||||
|
meta.Content = data.FormValue("content")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Page) Title() string {
|
||||||
|
return e.meta.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Page) Content() model.EntryContent {
|
||||||
|
str, err := render.RenderTemplateToString("entry/Page", e)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
return model.EntryContent(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Page) MetaData() model.EntryMetaData {
|
||||||
|
return &e.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Page) SetMetaData(metaData model.EntryMetaData) {
|
||||||
|
e.meta = *metaData.(*PageMetaData)
|
||||||
|
}
|
|
@ -0,0 +1,64 @@
|
||||||
|
package entrytypes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"owl-blogs/render"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Recipe struct {
|
||||||
|
model.EntryBase
|
||||||
|
meta RecipeMetaData
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecipeMetaData struct {
|
||||||
|
Title string
|
||||||
|
Yield string
|
||||||
|
Duration string
|
||||||
|
Ingredients []string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form implements model.EntryMetaData.
|
||||||
|
func (meta *RecipeMetaData) Form(binSvc model.BinaryStorageInterface) string {
|
||||||
|
f, _ := render.RenderTemplateToString("forms/Recipe", meta)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFormData implements model.EntryMetaData.
|
||||||
|
func (meta *RecipeMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
|
||||||
|
ings := strings.Split(data.FormValue("ingredients"), "\n")
|
||||||
|
clean := make([]string, 0)
|
||||||
|
for _, ing := range ings {
|
||||||
|
if strings.TrimSpace(ing) != "" {
|
||||||
|
clean = append(clean, strings.TrimSpace(ing))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meta.Title = data.FormValue("title")
|
||||||
|
meta.Yield = data.FormValue("yield")
|
||||||
|
meta.Duration = data.FormValue("duration")
|
||||||
|
meta.Ingredients = clean
|
||||||
|
meta.Content = data.FormValue("content")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Recipe) Title() string {
|
||||||
|
return e.meta.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Recipe) Content() model.EntryContent {
|
||||||
|
str, err := render.RenderTemplateToString("entry/Recipe", e)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
return model.EntryContent(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Recipe) MetaData() model.EntryMetaData {
|
||||||
|
return &e.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Recipe) SetMetaData(metaData model.EntryMetaData) {
|
||||||
|
e.meta = *metaData.(*RecipeMetaData)
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
package entrytypes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"owl-blogs/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Reply struct {
|
||||||
|
model.EntryBase
|
||||||
|
meta ReplyMetaData
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReplyMetaData struct {
|
||||||
|
Title string
|
||||||
|
Url string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form implements model.EntryMetaData.
|
||||||
|
func (meta *ReplyMetaData) Form(binSvc model.BinaryStorageInterface) string {
|
||||||
|
f, _ := render.RenderTemplateToString("forms/Reply", meta)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFormData implements model.EntryMetaData.
|
||||||
|
func (meta *ReplyMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
|
||||||
|
meta.Title = data.FormValue("title")
|
||||||
|
meta.Url = data.FormValue("url")
|
||||||
|
meta.Content = data.FormValue("content")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Reply) Title() string {
|
||||||
|
return "Re: " + e.meta.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Reply) Content() model.EntryContent {
|
||||||
|
str, err := render.RenderTemplateToString("entry/Reply", e)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
return model.EntryContent(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Reply) MetaData() model.EntryMetaData {
|
||||||
|
return &e.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Reply) SetMetaData(metaData model.EntryMetaData) {
|
||||||
|
e.meta = *metaData.(*ReplyMetaData)
|
||||||
|
}
|
48
go.mod
48
go.mod
|
@ -1,16 +1,50 @@
|
||||||
module h4kor/owl-blogs
|
module owl-blogs
|
||||||
|
|
||||||
go 1.18
|
go 1.22
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/julienschmidt/httprouter v1.3.0
|
github.com/Davincible/goinsta/v3 v3.2.6
|
||||||
github.com/spf13/cobra v1.5.0
|
github.com/go-ap/activitypub v0.0.0-20240408091739-ba76b44c2594
|
||||||
github.com/yuin/goldmark v1.4.13
|
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73
|
||||||
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b
|
github.com/gofiber/fiber/v2 v2.52.4
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22
|
||||||
|
github.com/spf13/cobra v1.8.0
|
||||||
|
github.com/stretchr/testify v1.8.4
|
||||||
|
github.com/yuin/goldmark v1.7.1
|
||||||
|
golang.org/x/crypto v0.23.0
|
||||||
|
golang.org/x/net v0.25.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/inconshreveable/mousetrap v1.0.1 // indirect
|
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20240501202034-ef67d660e9fd // indirect
|
||||||
|
github.com/chromedp/chromedp v0.9.5 // indirect
|
||||||
|
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
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.8 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasthttp v1.52.0 // indirect
|
||||||
|
github.com/valyala/fastjson v1.6.4 // indirect
|
||||||
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
|
golang.org/x/sys v0.20.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
114
go.sum
114
go.sum
|
@ -1,19 +1,109 @@
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
|
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:cliQ4HHsCo6xi2oWZYKWW4bly/Ory9FuTpFPRxj/mAg=
|
||||||
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs=
|
||||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
github.com/Davincible/goinsta/v3 v3.2.6 h1:+lNIWU6NABWd2VSGe83UQypnef+kzWwjmfgGihPbwD8=
|
||||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
github.com/Davincible/goinsta/v3 v3.2.6/go.mod h1:jIDhrWZmttL/gtXj/mkCaZyeNdAAqW3UYjasOUW0YEw=
|
||||||
|
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||||
|
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20240501202034-ef67d660e9fd h1:5/HXKq8EaAWVmnl6Hnyl4SVq7FF5990DBW6AuTrWtVw=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20240501202034-ef67d660e9fd/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||||
|
github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
|
||||||
|
github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y=
|
||||||
|
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||||
|
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-ap/activitypub v0.0.0-20240408091739-ba76b44c2594 h1:er3GvGCm7bJwHostjZlsRy7uiUuCquUVF9Fe0TrwiPI=
|
||||||
|
github.com/go-ap/activitypub v0.0.0-20240408091739-ba76b44c2594/go.mod h1:yRUfFCoZY6C1CWalauqEQ5xYgSckzEBEO/2MBC6BOME=
|
||||||
|
github.com/go-ap/errors v0.0.0-20240304112515-6077fa9c17b0 h1:H9MGShwybHLSln6K8RxHPMHiLcD86Lru+5TVW2TcXHY=
|
||||||
|
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=
|
||||||
|
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||||
|
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||||
|
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
|
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||||
|
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||||
|
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM=
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
|
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
|
||||||
|
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||||
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||||
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
|
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||||
github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
|
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0=
|
||||||
|
github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ=
|
||||||
|
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
|
||||||
|
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||||
|
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
|
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
package importer
|
||||||
|
|
||||||
|
type V1UserConfig struct {
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
SubTitle string `yaml:"subtitle"`
|
||||||
|
HeaderColor string `yaml:"header_color"`
|
||||||
|
AuthorName string `yaml:"author_name"`
|
||||||
|
Me []V1UserMe `yaml:"me"`
|
||||||
|
PassworHash string `yaml:"password_hash"`
|
||||||
|
Lists []V1PostList `yaml:"lists"`
|
||||||
|
PrimaryListInclude []string `yaml:"primary_list_include"`
|
||||||
|
HeaderMenu []V1MenuItem `yaml:"header_menu"`
|
||||||
|
FooterMenu []V1MenuItem `yaml:"footer_menu"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V1UserMe struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Url string `yaml:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V1PostList struct {
|
||||||
|
Id string `yaml:"id"`
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
Include []string `yaml:"include"`
|
||||||
|
ListType string `yaml:"list_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V1MenuItem struct {
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
List string `yaml:"list"`
|
||||||
|
Url string `yaml:"url"`
|
||||||
|
Post string `yaml:"post"`
|
||||||
|
}
|
|
@ -0,0 +1,238 @@
|
||||||
|
package importer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"owl-blogs/app"
|
||||||
|
entrytypes "owl-blogs/entry_types"
|
||||||
|
"path"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReplyData struct {
|
||||||
|
Url string `yaml:"url"`
|
||||||
|
Text string `yaml:"text"`
|
||||||
|
}
|
||||||
|
type BookmarkData struct {
|
||||||
|
Url string `yaml:"url"`
|
||||||
|
Text string `yaml:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecipeData struct {
|
||||||
|
Yield string `yaml:"yield"`
|
||||||
|
Duration string `yaml:"duration"`
|
||||||
|
Ingredients []string `yaml:"ingredients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostMeta struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Aliases []string `yaml:"aliases"`
|
||||||
|
Date time.Time `yaml:"date"`
|
||||||
|
Draft bool `yaml:"draft"`
|
||||||
|
Reply ReplyData `yaml:"reply"`
|
||||||
|
Bookmark BookmarkData `yaml:"bookmark"`
|
||||||
|
Recipe RecipeData `yaml:"recipe"`
|
||||||
|
PhotoPath string `yaml:"photo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Post struct {
|
||||||
|
Id string
|
||||||
|
Meta PostMeta
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (post *Post) MediaDir() string {
|
||||||
|
return path.Join("public", post.Id, "media")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PostMeta) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||||
|
type T struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Aliases []string `yaml:"aliases"`
|
||||||
|
Draft bool `yaml:"draft"`
|
||||||
|
Reply ReplyData `yaml:"reply"`
|
||||||
|
Bookmark BookmarkData `yaml:"bookmark"`
|
||||||
|
Recipe RecipeData `yaml:"recipe"`
|
||||||
|
PhotoPath string `yaml:"photo"`
|
||||||
|
}
|
||||||
|
type S struct {
|
||||||
|
Date string `yaml:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var t T
|
||||||
|
var s S
|
||||||
|
if err := unmarshal(&t); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := unmarshal(&s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.Type = t.Type
|
||||||
|
if pm.Type == "" {
|
||||||
|
pm.Type = "article"
|
||||||
|
}
|
||||||
|
pm.Title = t.Title
|
||||||
|
pm.Description = t.Description
|
||||||
|
pm.Aliases = t.Aliases
|
||||||
|
pm.Draft = t.Draft
|
||||||
|
pm.Reply = t.Reply
|
||||||
|
pm.Bookmark = t.Bookmark
|
||||||
|
pm.Recipe = t.Recipe
|
||||||
|
pm.PhotoPath = t.PhotoPath
|
||||||
|
|
||||||
|
possibleFormats := []string{
|
||||||
|
"2006-01-02",
|
||||||
|
time.Layout,
|
||||||
|
time.ANSIC,
|
||||||
|
time.UnixDate,
|
||||||
|
time.RubyDate,
|
||||||
|
time.RFC822,
|
||||||
|
time.RFC822Z,
|
||||||
|
time.RFC850,
|
||||||
|
time.RFC1123,
|
||||||
|
time.RFC1123Z,
|
||||||
|
time.RFC3339,
|
||||||
|
time.RFC3339Nano,
|
||||||
|
time.Stamp,
|
||||||
|
time.StampMilli,
|
||||||
|
time.StampMicro,
|
||||||
|
time.StampNano,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, format := range possibleFormats {
|
||||||
|
if t, err := time.Parse(format, s.Date); err == nil {
|
||||||
|
pm.Date = t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadContent(data []byte) string {
|
||||||
|
|
||||||
|
// trim yaml block
|
||||||
|
// TODO this can be done nicer
|
||||||
|
trimmedData := bytes.TrimSpace(data)
|
||||||
|
// ensure that data ends with a newline
|
||||||
|
trimmedData = append(trimmedData, []byte("\n")...)
|
||||||
|
// check first line is ---
|
||||||
|
if string(trimmedData[0:4]) == "---\n" {
|
||||||
|
trimmedData = trimmedData[4:]
|
||||||
|
// find --- end
|
||||||
|
end := bytes.Index(trimmedData, []byte("\n---\n"))
|
||||||
|
if end != -1 {
|
||||||
|
data = trimmedData[end+5:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadMeta(data []byte) (PostMeta, error) {
|
||||||
|
|
||||||
|
// get yaml metadata block
|
||||||
|
meta := PostMeta{}
|
||||||
|
trimmedData := bytes.TrimSpace(data)
|
||||||
|
// ensure that data ends with a newline
|
||||||
|
trimmedData = append(trimmedData, []byte("\n")...)
|
||||||
|
// check first line is ---
|
||||||
|
if string(trimmedData[0:4]) == "---\n" {
|
||||||
|
trimmedData = trimmedData[4:]
|
||||||
|
// find --- end
|
||||||
|
end := bytes.Index(trimmedData, []byte("---\n"))
|
||||||
|
if end != -1 {
|
||||||
|
metaData := trimmedData[:end]
|
||||||
|
err := yaml.Unmarshal(metaData, &meta)
|
||||||
|
if err != nil {
|
||||||
|
return PostMeta{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AllUserPosts(userPath string) ([]Post, error) {
|
||||||
|
postFiles := ListDir(path.Join(userPath, "public"))
|
||||||
|
posts := make([]Post, 0)
|
||||||
|
for _, id := range postFiles {
|
||||||
|
// if is a directory and has index.md, add to posts
|
||||||
|
if dirExists(path.Join(userPath, "public", id)) {
|
||||||
|
if fileExists(path.Join(userPath, "public", id, "index.md")) {
|
||||||
|
postData, err := os.ReadFile(path.Join(userPath, "public", id, "index.md"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
meta, err := LoadMeta(postData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
post := Post{
|
||||||
|
Id: id,
|
||||||
|
Content: LoadContent(postData),
|
||||||
|
Meta: meta,
|
||||||
|
}
|
||||||
|
posts = append(posts, post)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListDir(path string) []string {
|
||||||
|
dir, _ := os.Open(path)
|
||||||
|
defer dir.Close()
|
||||||
|
files, _ := dir.Readdirnames(-1)
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertTypeList(v1 []string, registry *app.EntryTypeRegistry) []string {
|
||||||
|
v2 := make([]string, len(v1))
|
||||||
|
for i, v1Type := range v1 {
|
||||||
|
switch v1Type {
|
||||||
|
case "article":
|
||||||
|
name, _ := registry.TypeName(&entrytypes.Article{})
|
||||||
|
v2[i] = name
|
||||||
|
case "bookmark":
|
||||||
|
name, _ := registry.TypeName(&entrytypes.Bookmark{})
|
||||||
|
v2[i] = name
|
||||||
|
case "reply":
|
||||||
|
name, _ := registry.TypeName(&entrytypes.Reply{})
|
||||||
|
v2[i] = name
|
||||||
|
case "photo":
|
||||||
|
name, _ := registry.TypeName(&entrytypes.Image{})
|
||||||
|
v2[i] = name
|
||||||
|
case "note":
|
||||||
|
name, _ := registry.TypeName(&entrytypes.Note{})
|
||||||
|
v2[i] = name
|
||||||
|
case "recipe":
|
||||||
|
name, _ := registry.TypeName(&entrytypes.Recipe{})
|
||||||
|
v2[i] = name
|
||||||
|
case "page":
|
||||||
|
name, _ := registry.TypeName(&entrytypes.Page{})
|
||||||
|
v2[i] = name
|
||||||
|
default:
|
||||||
|
v2[i] = v1Type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v2
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sqlAuthor struct {
|
||||||
|
Name string `db:"name"`
|
||||||
|
PasswordHash string `db:"password_hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultAuthorRepo struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDefaultAuthorRepo(db Database) *DefaultAuthorRepo {
|
||||||
|
sqlxdb := db.Get()
|
||||||
|
|
||||||
|
// Create table if not exists
|
||||||
|
sqlxdb.MustExec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS authors (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
password_hash TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
return &DefaultAuthorRepo{
|
||||||
|
db: sqlxdb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByName implements repository.AuthorRepository.
|
||||||
|
func (r *DefaultAuthorRepo) FindByName(name string) (*model.Author, error) {
|
||||||
|
var author sqlAuthor
|
||||||
|
err := r.db.Get(&author, "SELECT * FROM authors WHERE name = ?", name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &model.Author{
|
||||||
|
Name: author.Name,
|
||||||
|
PasswordHash: author.PasswordHash,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create implements repository.AuthorRepository.
|
||||||
|
func (r *DefaultAuthorRepo) Create(name string, passwordHash string) (*model.Author, error) {
|
||||||
|
author := sqlAuthor{
|
||||||
|
Name: name,
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
}
|
||||||
|
_, err := r.db.NamedExec("INSERT INTO authors (name, password_hash) VALUES (:name, :password_hash)", author)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &model.Author{
|
||||||
|
Name: author.Name,
|
||||||
|
PasswordHash: author.PasswordHash,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DefaultAuthorRepo) Update(author *model.Author) error {
|
||||||
|
sqlA := sqlAuthor{
|
||||||
|
Name: author.Name,
|
||||||
|
PasswordHash: author.PasswordHash,
|
||||||
|
}
|
||||||
|
_, err := r.db.NamedExec("UPDATE authors SET password_hash = :password_hash WHERE name = :name", sqlA)
|
||||||
|
return err
|
||||||
|
}
|
|
@ -0,0 +1,63 @@
|
||||||
|
package infra_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
"owl-blogs/infra"
|
||||||
|
"owl-blogs/test"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupAutherRepo() repository.AuthorRepository {
|
||||||
|
db := test.NewMockDb()
|
||||||
|
repo := infra.NewDefaultAuthorRepo(db)
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorRepoCreate(t *testing.T) {
|
||||||
|
repo := setupAutherRepo()
|
||||||
|
|
||||||
|
author, err := repo.Create("name", "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
author, err = repo.FindByName(author.Name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, author.Name, "name")
|
||||||
|
require.Equal(t, author.PasswordHash, "password")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorRepoNoSideEffect(t *testing.T) {
|
||||||
|
repo := setupAutherRepo()
|
||||||
|
|
||||||
|
author, err := repo.Create("name1", "password1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
author2, err := repo.Create("name2", "password2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
author, err = repo.FindByName(author.Name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
author2, err = repo.FindByName(author2.Name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, author.Name, "name1")
|
||||||
|
require.Equal(t, author.PasswordHash, "password1")
|
||||||
|
require.Equal(t, author2.Name, "name2")
|
||||||
|
require.Equal(t, author2.PasswordHash, "password2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorUpdate(t *testing.T) {
|
||||||
|
repo := setupAutherRepo()
|
||||||
|
|
||||||
|
author, err := repo.Create("name1", "password1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
author.PasswordHash = "password2"
|
||||||
|
err = repo.Update(author)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
author, err = repo.FindByName("name1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, author.PasswordHash, "password2")
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,125 @@
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sqlBinaryFile struct {
|
||||||
|
Id string `db:"id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
EntryId *string `db:"entry_id"`
|
||||||
|
Data []byte `db:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultBinaryFileRepo struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBinaryFileRepo creates a new binary file repository
|
||||||
|
// It creates the table if not exists
|
||||||
|
func NewBinaryFileRepo(db Database) repository.BinaryRepository {
|
||||||
|
sqlxdb := db.Get()
|
||||||
|
|
||||||
|
// Create table if not exists
|
||||||
|
sqlxdb.MustExec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS binary_files (
|
||||||
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
entry_id VARCHAR(255),
|
||||||
|
data BLOB NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
return &DefaultBinaryFileRepo{db: sqlxdb}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create implements repository.BinaryRepository
|
||||||
|
func (repo *DefaultBinaryFileRepo) Create(name string, data []byte, entry model.Entry) (*model.BinaryFile, error) {
|
||||||
|
parts := strings.Split(name, ".")
|
||||||
|
fileName := strings.Join(parts[:len(parts)-1], ".")
|
||||||
|
fileExt := parts[len(parts)-1]
|
||||||
|
id := fileName + "." + fileExt
|
||||||
|
|
||||||
|
// check if id exists
|
||||||
|
var count int
|
||||||
|
err := repo.db.Get(&count, "SELECT COUNT(*) FROM binary_files WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
counter := 1
|
||||||
|
for {
|
||||||
|
id = fmt.Sprintf("%s-%d.%s", fileName, counter, fileExt)
|
||||||
|
err := repo.db.Get(&count, "SELECT COUNT(*) FROM binary_files WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var entryId *string
|
||||||
|
if entry != nil {
|
||||||
|
eId := entry.ID()
|
||||||
|
entryId = &eId
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = repo.db.Exec("INSERT INTO binary_files (id, name, entry_id, data) VALUES (?, ?, ?, ?)", id, name, entryId, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &model.BinaryFile{Id: id, Name: name, Data: data}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindById implements repository.BinaryRepository
|
||||||
|
func (repo *DefaultBinaryFileRepo) FindById(id string) (*model.BinaryFile, error) {
|
||||||
|
var sqlFile sqlBinaryFile
|
||||||
|
err := repo.db.Get(&sqlFile, "SELECT * FROM binary_files WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &model.BinaryFile{Id: sqlFile.Id, Name: sqlFile.Name, Data: sqlFile.Data}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByNameForEntry implements repository.BinaryRepository
|
||||||
|
func (repo *DefaultBinaryFileRepo) FindByNameForEntry(name string, entry model.Entry) (*model.BinaryFile, error) {
|
||||||
|
var sqlFile sqlBinaryFile
|
||||||
|
err := repo.db.Get(&sqlFile, "SELECT * FROM binary_files WHERE name = ? AND entry_id = ?", name, entry.ID())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &model.BinaryFile{Id: sqlFile.Id, Name: sqlFile.Name, Data: sqlFile.Data}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIds implements repository.BinaryRepository
|
||||||
|
func (repo *DefaultBinaryFileRepo) ListIds(filter string) ([]string, error) {
|
||||||
|
filter = strings.TrimSpace(strings.ToLower(filter))
|
||||||
|
if filter == "" {
|
||||||
|
filter = "%"
|
||||||
|
} else {
|
||||||
|
filter = "%" + filter + "%"
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
err := repo.db.Select(&ids, "SELECT id FROM binary_files WHERE LOWER(id) LIKE ?", filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete implements repository.BinaryRepository
|
||||||
|
func (repo *DefaultBinaryFileRepo) Delete(binary *model.BinaryFile) error {
|
||||||
|
id := binary.Id
|
||||||
|
println("Deleting binary file", id)
|
||||||
|
_, err := repo.db.Exec("DELETE FROM binary_files WHERE id = ?", id)
|
||||||
|
return err
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
package infra_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
"owl-blogs/infra"
|
||||||
|
"owl-blogs/test"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupBinaryRepo() repository.BinaryRepository {
|
||||||
|
db := test.NewMockDb()
|
||||||
|
repo := infra.NewBinaryFileRepo(db)
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBinaryRepoCreate(t *testing.T) {
|
||||||
|
repo := setupBinaryRepo()
|
||||||
|
|
||||||
|
file, err := repo.Create("name", []byte("😀 😃 😄 😁"), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
file, err = repo.FindById(file.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, file.Name, "name")
|
||||||
|
require.Equal(t, file.Data, []byte("😀 😃 😄 😁"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBinaryRepoNoSideEffect(t *testing.T) {
|
||||||
|
repo := setupBinaryRepo()
|
||||||
|
|
||||||
|
file, err := repo.Create("name1", []byte("111"), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
file2, err := repo.Create("name2", []byte("222"), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
file, err = repo.FindById(file.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
file2, err = repo.FindById(file2.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, file.Name, "name1")
|
||||||
|
require.Equal(t, file.Data, []byte("111"))
|
||||||
|
require.Equal(t, file2.Name, "name2")
|
||||||
|
require.Equal(t, file2.Data, []byte("222"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBinaryWithSpaceInName(t *testing.T) {
|
||||||
|
repo := setupBinaryRepo()
|
||||||
|
|
||||||
|
file, err := repo.Create("name with space", []byte("111"), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
file, err = repo.FindById(file.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, file.Name, "name with space")
|
||||||
|
require.Equal(t, file.Data, []byte("111"))
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DefaultConfigRepo struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfigRepo(db Database) repository.ConfigRepository {
|
||||||
|
sqlxdb := db.Get()
|
||||||
|
|
||||||
|
sqlxdb.MustExec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS site_config (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
config TEXT
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
return &DefaultConfigRepo{
|
||||||
|
db: sqlxdb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get implements repository.SiteConfigRepository.
|
||||||
|
func (r *DefaultConfigRepo) Get(name string, result interface{}) error {
|
||||||
|
data := []byte{}
|
||||||
|
err := r.db.Get(&data, "SELECT config FROM site_config WHERE name = ?", name)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "sql: no rows in result set" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update implements repository.SiteConfigRepository.
|
||||||
|
func (r *DefaultConfigRepo) Update(name string, siteConfig interface{}) error {
|
||||||
|
jsonData, err := json.Marshal(siteConfig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := r.db.Exec("UPDATE site_config SET config = ? WHERE name = ?", jsonData, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
_, err = r.db.Exec("INSERT INTO site_config (name, config) VALUES (?, ?)", name, jsonData)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
package infra_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"owl-blogs/infra"
|
||||||
|
"owl-blogs/test"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupSiteConfigRepo() repository.ConfigRepository {
|
||||||
|
db := test.NewMockDb()
|
||||||
|
repo := infra.NewConfigRepo(db)
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSiteConfigRepo(t *testing.T) {
|
||||||
|
repo := setupSiteConfigRepo()
|
||||||
|
|
||||||
|
config := model.SiteConfig{}
|
||||||
|
err := repo.Get("test", &config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "", config.Title)
|
||||||
|
require.Equal(t, "", config.SubTitle)
|
||||||
|
|
||||||
|
config.Title = "title"
|
||||||
|
config.SubTitle = "SubTitle"
|
||||||
|
|
||||||
|
err = repo.Update("test", config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
config2 := model.SiteConfig{}
|
||||||
|
err = repo.Get("test", &config2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "title", config2.Title)
|
||||||
|
require.Equal(t, "SubTitle", config2.SubTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSiteConfigUpdates(t *testing.T) {
|
||||||
|
repo := setupSiteConfigRepo()
|
||||||
|
config := model.SiteConfig{}
|
||||||
|
err := repo.Get("test", &config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "", config.Title)
|
||||||
|
require.Equal(t, "", config.SubTitle)
|
||||||
|
|
||||||
|
config.Title = "title"
|
||||||
|
config.SubTitle = "SubTitle"
|
||||||
|
|
||||||
|
err = repo.Update("test", config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
config2 := model.SiteConfig{}
|
||||||
|
err = repo.Get("test", &config2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "title", config2.Title)
|
||||||
|
require.Equal(t, "SubTitle", config2.SubTitle)
|
||||||
|
|
||||||
|
config2.Title = "title2"
|
||||||
|
config2.SubTitle = "SubTitle2"
|
||||||
|
|
||||||
|
err = repo.Update("test", config2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
config3 := model.SiteConfig{}
|
||||||
|
err = repo.Get("test", &config3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "title2", config3.Title)
|
||||||
|
require.Equal(t, "SubTitle2", config3.SubTitle)
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,161 @@
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"owl-blogs/app"
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sqlEntry struct {
|
||||||
|
Id string `db:"id"`
|
||||||
|
Type string `db:"type"`
|
||||||
|
PublishedAt *time.Time `db:"published_at"`
|
||||||
|
MetaData *string `db:"meta_data"`
|
||||||
|
AuthorId string `db:"author_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultEntryRepo struct {
|
||||||
|
typeRegistry *app.EntryTypeRegistry
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEntryRepository(db Database, register *app.EntryTypeRegistry) repository.EntryRepository {
|
||||||
|
sqlxdb := db.Get()
|
||||||
|
|
||||||
|
// Create tables if not exists
|
||||||
|
sqlxdb.MustExec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
published_at DATETIME,
|
||||||
|
author_id TEXT NOT NULL,
|
||||||
|
meta_data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
return &DefaultEntryRepo{
|
||||||
|
db: sqlxdb,
|
||||||
|
typeRegistry: register,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create implements repository.EntryRepository.
|
||||||
|
func (r *DefaultEntryRepo) Create(entry model.Entry) error {
|
||||||
|
t, err := r.typeRegistry.TypeName(entry)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("entry type not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
var metaDataJson []byte
|
||||||
|
if entry.MetaData() != nil {
|
||||||
|
metaDataJson, _ = json.Marshal(entry.MetaData())
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.ID() == "" {
|
||||||
|
entry.SetID(uuid.New().String())
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.db.Exec("INSERT INTO entries (id, type, published_at, author_id, meta_data) VALUES (?, ?, ?, ?, ?)", entry.ID(), t, entry.PublishedAt(), entry.AuthorId(), metaDataJson)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete implements repository.EntryRepository.
|
||||||
|
func (r *DefaultEntryRepo) Delete(entry model.Entry) error {
|
||||||
|
if entry.ID() == "" {
|
||||||
|
return errors.New("entry not found")
|
||||||
|
}
|
||||||
|
_, err := r.db.Exec("DELETE FROM entries WHERE id = ?", entry.ID())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAll implements repository.EntryRepository.
|
||||||
|
func (r *DefaultEntryRepo) FindAll(types *[]string) ([]model.Entry, error) {
|
||||||
|
filterStr := ""
|
||||||
|
if types != nil {
|
||||||
|
filters := []string{}
|
||||||
|
for _, t := range *types {
|
||||||
|
filters = append(filters, "type = '"+t+"'")
|
||||||
|
}
|
||||||
|
filterStr = strings.Join(filters, " OR ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []sqlEntry
|
||||||
|
if filterStr != "" {
|
||||||
|
err := r.db.Select(&entries, "SELECT * FROM entries WHERE "+filterStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err := r.db.Select(&entries, "SELECT * FROM entries")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := []model.Entry{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
e, err := r.sqlEntryToEntry(entry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, e)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindById implements repository.EntryRepository.
|
||||||
|
func (r *DefaultEntryRepo) FindById(id string) (model.Entry, error) {
|
||||||
|
data := sqlEntry{}
|
||||||
|
err := r.db.Get(&data, "SELECT * FROM entries WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if data.Id == "" {
|
||||||
|
return nil, errors.New("entry not found")
|
||||||
|
}
|
||||||
|
return r.sqlEntryToEntry(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update implements repository.EntryRepository.
|
||||||
|
func (r *DefaultEntryRepo) Update(entry model.Entry) error {
|
||||||
|
exEntry, _ := r.FindById(entry.ID())
|
||||||
|
if exEntry == nil {
|
||||||
|
return errors.New("entry not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := r.typeRegistry.TypeName(entry)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("entry type not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
var metaDataJson []byte
|
||||||
|
if entry.MetaData() != nil {
|
||||||
|
metaDataJson, _ = json.Marshal(entry.MetaData())
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.db.Exec("UPDATE entries SET published_at = ?, author_id = ?, meta_data = ? WHERE id = ?", entry.PublishedAt(), entry.AuthorId(), metaDataJson, entry.ID())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DefaultEntryRepo) sqlEntryToEntry(entry sqlEntry) (model.Entry, error) {
|
||||||
|
e, err := r.typeRegistry.Type(entry.Type)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("entry type not registered")
|
||||||
|
}
|
||||||
|
metaData := reflect.New(reflect.TypeOf(e.MetaData()).Elem()).Interface().(model.EntryMetaData)
|
||||||
|
json.Unmarshal([]byte(*entry.MetaData), metaData)
|
||||||
|
e.SetID(entry.Id)
|
||||||
|
e.SetPublishedAt(entry.PublishedAt)
|
||||||
|
e.SetMetaData(metaData)
|
||||||
|
e.SetAuthorId(entry.AuthorId)
|
||||||
|
return e, nil
|
||||||
|
}
|
|
@ -0,0 +1,192 @@
|
||||||
|
package infra_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/app"
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
"owl-blogs/infra"
|
||||||
|
"owl-blogs/test"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupRepo() repository.EntryRepository {
|
||||||
|
db := test.NewMockDb()
|
||||||
|
register := app.NewEntryTypeRegistry()
|
||||||
|
register.Register(&test.MockEntry{})
|
||||||
|
repo := infra.NewEntryRepository(db, register)
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoCreate(t *testing.T) {
|
||||||
|
repo := setupRepo()
|
||||||
|
|
||||||
|
entry := &test.MockEntry{}
|
||||||
|
now := time.Now()
|
||||||
|
entry.SetPublishedAt(&now)
|
||||||
|
entry.SetAuthorId("authorId")
|
||||||
|
entry.SetMetaData(&test.MockEntryMetaData{
|
||||||
|
Str: "str",
|
||||||
|
Number: 1,
|
||||||
|
Date: now,
|
||||||
|
})
|
||||||
|
err := repo.Create(entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
entry2, err := repo.FindById(entry.ID())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, entry.ID(), entry2.ID())
|
||||||
|
require.Equal(t, entry.Content(), entry2.Content())
|
||||||
|
require.Equal(t, entry.AuthorId(), entry2.AuthorId())
|
||||||
|
require.Equal(t, entry.PublishedAt().Unix(), entry2.PublishedAt().Unix())
|
||||||
|
meta := entry.MetaData().(*test.MockEntryMetaData)
|
||||||
|
meta2 := entry2.MetaData().(*test.MockEntryMetaData)
|
||||||
|
require.Equal(t, meta.Str, meta2.Str)
|
||||||
|
require.Equal(t, meta.Number, meta2.Number)
|
||||||
|
require.Equal(t, meta.Date.Unix(), meta2.Date.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoDelete(t *testing.T) {
|
||||||
|
repo := setupRepo()
|
||||||
|
|
||||||
|
entry := &test.MockEntry{}
|
||||||
|
now := time.Now()
|
||||||
|
entry.SetPublishedAt(&now)
|
||||||
|
entry.SetMetaData(&test.MockEntryMetaData{
|
||||||
|
Str: "str",
|
||||||
|
Number: 1,
|
||||||
|
Date: now,
|
||||||
|
})
|
||||||
|
err := repo.Create(entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = repo.Delete(entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = repo.FindById("id")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoFindAll(t *testing.T) {
|
||||||
|
repo := setupRepo()
|
||||||
|
|
||||||
|
entry := &test.MockEntry{}
|
||||||
|
now := time.Now()
|
||||||
|
entry.SetPublishedAt(&now)
|
||||||
|
entry.SetMetaData(&test.MockEntryMetaData{
|
||||||
|
Str: "str",
|
||||||
|
Number: 1,
|
||||||
|
Date: now,
|
||||||
|
})
|
||||||
|
err := repo.Create(entry)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
entry2 := &test.MockEntry{}
|
||||||
|
now2 := time.Now()
|
||||||
|
entry2.SetPublishedAt(&now2)
|
||||||
|
entry2.SetMetaData(&test.MockEntryMetaData{
|
||||||
|
Str: "str",
|
||||||
|
Number: 1,
|
||||||
|
Date: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
err = repo.Create(entry2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
entries, err := repo.FindAll(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 2, len(entries))
|
||||||
|
|
||||||
|
entries, err = repo.FindAll(&[]string{"MockEntry"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 2, len(entries))
|
||||||
|
|
||||||
|
entries, err = repo.FindAll(&[]string{"MockEntry2"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 0, len(entries))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoUpdate(t *testing.T) {
|
||||||
|
repo := setupRepo()
|
||||||
|
|
||||||
|
entry := &test.MockEntry{}
|
||||||
|
now := time.Now()
|
||||||
|
entry.SetPublishedAt(&now)
|
||||||
|
entry.SetAuthorId("authorId")
|
||||||
|
entry.SetMetaData(&test.MockEntryMetaData{
|
||||||
|
Str: "str",
|
||||||
|
Number: 1,
|
||||||
|
Date: now,
|
||||||
|
})
|
||||||
|
err := repo.Create(entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
entry2 := &test.MockEntry{}
|
||||||
|
now2 := time.Now()
|
||||||
|
entry2.SetPublishedAt(&now2)
|
||||||
|
entry.SetAuthorId("authorId2")
|
||||||
|
entry2.SetMetaData(&test.MockEntryMetaData{
|
||||||
|
Str: "str2",
|
||||||
|
Number: 2,
|
||||||
|
Date: now2,
|
||||||
|
})
|
||||||
|
err = repo.Create(entry2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = repo.Update(entry2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
entry3, err := repo.FindById(entry2.ID())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, entry3.Content(), entry2.Content())
|
||||||
|
require.Equal(t, entry3.PublishedAt().Unix(), entry2.PublishedAt().Unix())
|
||||||
|
require.Equal(t, entry3.AuthorId(), entry2.AuthorId())
|
||||||
|
meta := entry3.MetaData().(*test.MockEntryMetaData)
|
||||||
|
meta2 := entry2.MetaData().(*test.MockEntryMetaData)
|
||||||
|
require.Equal(t, meta.Str, meta2.Str)
|
||||||
|
require.Equal(t, meta.Number, meta2.Number)
|
||||||
|
require.Equal(t, meta.Date.Unix(), meta2.Date.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoNoSideEffect(t *testing.T) {
|
||||||
|
repo := setupRepo()
|
||||||
|
|
||||||
|
entry1 := &test.MockEntry{}
|
||||||
|
now1 := time.Now()
|
||||||
|
entry1.SetPublishedAt(&now1)
|
||||||
|
entry1.SetMetaData(&test.MockEntryMetaData{
|
||||||
|
Str: "1",
|
||||||
|
Number: 1,
|
||||||
|
Date: now1,
|
||||||
|
})
|
||||||
|
|
||||||
|
err := repo.Create(entry1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
entry2 := &test.MockEntry{}
|
||||||
|
now2 := time.Now()
|
||||||
|
entry2.SetPublishedAt(&now2)
|
||||||
|
entry2.SetMetaData(&test.MockEntryMetaData{
|
||||||
|
Str: "2",
|
||||||
|
Number: 2,
|
||||||
|
Date: now2,
|
||||||
|
})
|
||||||
|
err = repo.Create(entry2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
r1, err := repo.FindById(entry1.ID())
|
||||||
|
require.NoError(t, err)
|
||||||
|
r2, err := repo.FindById(entry2.ID())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Equal(t, r1.MetaData().(*test.MockEntryMetaData).Str, "1")
|
||||||
|
require.Equal(t, r1.MetaData().(*test.MockEntryMetaData).Number, 1)
|
||||||
|
require.Equal(t, r1.MetaData().(*test.MockEntryMetaData).Date.Unix(), now1.Unix())
|
||||||
|
|
||||||
|
require.Equal(t, r2.MetaData().(*test.MockEntryMetaData).Str, "2")
|
||||||
|
require.Equal(t, r2.MetaData().(*test.MockEntryMetaData).Number, 2)
|
||||||
|
require.Equal(t, r2.MetaData().(*test.MockEntryMetaData).Date.Unix(), now2.Unix())
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
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)
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
type OwlHttpClient = http.Client
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue