Compare commits

...

246 Commits

Author SHA1 Message Date
Niko Abeler da06541e11 spacing var 2024-05-18 22:05:44 +02:00
Niko Abeler 7b047a609f powered by 2024-05-18 22:04:23 +02:00
Niko Abeler a5f24427a1 cleanup + primary color usage 2024-05-18 21:59:32 +02:00
Niko Abeler ec13fffbe9 release flow 3 2024-05-18 21:11:03 +02:00
Niko Abeler 686cd72ec2 release flow 2 2024-05-18 21:06:42 +02:00
Niko Abeler 29e875d2e5 release flow 2024-05-18 20:49:07 +02:00
Niko Abeler f958fa36fd fix test 2024-05-18 20:31:50 +02:00
Niko Abeler 1b347bcdac CI setup 4 2024-05-18 20:20:00 +02:00
Niko Abeler c8b759a834 CI setup 3 2024-05-18 20:16:47 +02:00
Niko Abeler 943bc10eaf CI setup 2 2024-05-18 20:00:19 +02:00
Niko Abeler 4543e448ed update requirements 2024-05-18 19:57:15 +02:00
Niko Abeler e0d6f4f223 CI setup 1 2024-05-18 19:54:58 +02:00
Niko Abeler 49a602f68b
Create test.yml 2024-05-18 19:53:06 +02:00
Niko Abeler 596ab0047e CI prep + README 2024-05-18 19:49:55 +02:00
Niko Abeler 5c05f48be3 prevent publishing of drafts + support for recipes 2024-05-18 19:34:44 +02:00
Niko Abeler 3c924ac8a4 support for images 2024-05-18 16:12:35 +02:00
Niko Abeler 26737ea21d remove files 2024-05-18 15:21:12 +02:00
Niko Abeler 29ae7e717f actor details 2024-05-18 13:10:14 +02:00
Niko Abeler 741ccfac73 support announce, publish deletions, add 'hashtags' to notes 2024-05-18 12:42:49 +02:00
Niko Abeler 9cfbf0b9b7 process likes from ActivityPub 2024-05-17 22:37:18 +02:00
Niko Abeler cba57ba708 first working toot 2024-05-17 21:05:13 +02:00
Niko Abeler ced3907880 move actor to main url 2024-05-17 20:23:38 +02:00
Niko Abeler 3cbf952ae6 sending accepts 2024-05-16 21:11:02 +02:00
Niko Abeler 0c8779def7 test follow/unfollow 2024-05-16 20:27:08 +02:00
Niko Abeler 652f81805d WIP mock amsto + tests 2024-05-14 21:23:37 +02:00
Niko Abeler 624f19a1d9 WIP copying code from fedi-games 2024-05-13 19:03:24 +02:00
Niko Abeler 10ca2bdcd9 add follower repository 2024-05-12 21:45:04 +02:00
Niko Abeler 633c0991e9 add me links again 2024-05-11 21:21:39 +02:00
Niko Abeler 2bf2e409b6 fixes for small screens 2024-05-11 19:46:47 +02:00
Niko Abeler 390a58b404 using owl styling 2024-05-11 16:33:29 +02:00
Niko Abeler d7e5df2a95 tables 2024-05-09 16:52:13 +02:00
Niko Abeler 3a3655b587 remove picocss files 2024-05-09 16:11:35 +02:00
Niko Abeler cba07e961d actions and forms 2024-05-09 16:11:13 +02:00
Niko Abeler 1524820d5e extending own stylesheet to remove pico 2024-05-09 14:15:19 +02:00
Niko Abeler 91b82f0e57 package updates 2024-05-09 11:13:42 +02:00
Niko Abeler 2a4b76ee03 add indication of upload error 2024-05-09 11:02:41 +02:00
Niko Abeler 4bdb920c71 add animation while uploading a dropped file 2024-05-09 10:58:32 +02:00
Niko Abeler ff10f6c5eb raise upload limit 2024-05-09 10:47:10 +02:00
Niko Abeler bc7b146e91 add @context to actor 2024-02-26 20:17:53 +01:00
Niko Abeler 7eb3bf0b44 update README with build and run 2024-02-25 20:29:41 +01:00
Niko Abeler 5cc55a79ff refactoring to have a dedicated site config service. Default config for new setups 2024-02-25 14:01:55 +01:00
Niko Abeler be62bcd627 use static favicon 2024-02-24 21:53:39 +01:00
Niko Abeler d794ad0865 remove empty tag 2024-02-24 21:22:23 +01:00
Niko Abeler f5946ea823 Open Graph Meta data 2024-02-24 21:21:48 +01:00
Niko Abeler 8200e3384c fix for interactions 2024-02-24 20:11:09 +01:00
Niko Abeler 0bf7c492c9 WIP interaction list 2024-02-24 19:53:49 +01:00
Niko Abeler 765698b1a6 compact file site in admin 2024-02-23 13:02:13 +01:00
Niko Abeler 1485a7efbe update to pico 2 2024-02-23 12:57:43 +01:00
Niko Abeler bc50388f58 filter for binary files 2024-02-23 12:34:05 +01:00
Niko Abeler 7ceb00799a add drop file to more forms 2024-02-23 10:15:06 +01:00
Niko Abeler 6d115dd74e finish drop file and moved into function for reusablility 2024-02-23 10:10:07 +01:00
Niko Abeler 4941b5d027 drag files into article 2024-02-21 21:21:58 +01:00
Niko Abeler 2f81bf8678 unified interface Formable 2024-02-21 20:24:18 +01:00
h4kor c36b9abbcf Merge pull request 'Better Editor Forms' (#56) from better_forms into main
Reviewed-on: #56
2024-02-21 19:05:07 +00:00
Niko Abeler 1613d3dd11 remove owl annotations 2024-02-21 20:04:22 +01:00
Niko Abeler bd11b88338 removal of forms 2024-02-21 20:03:18 +01:00
Niko Abeler 1fdcdff41d all entry types with custom forms 2024-02-14 21:05:50 +01:00
Niko Abeler eee2131a76 more custom forms 2024-02-14 20:43:43 +01:00
Niko Abeler fcc0132758 first custom form 2024-02-14 19:53:49 +01:00
Niko Abeler b86eee27ce WIP, large refactoring to allow custom form code per type 2024-02-13 21:43:52 +01:00
Niko Abeler 50997c051b slimmer action on entry 2024-01-25 22:12:14 +01:00
Niko Abeler 4bbaf3362e action tiles on main admin page 2024-01-24 22:04:15 +01:00
Niko Abeler c196a72d34 WIP grid buttons 2024-01-23 19:17:04 +01:00
Niko Abeler 94b6628411 fix build 2024-01-17 21:38:41 +01:00
Niko Abeler 8d3bdb6f4c ensure trailing slash on entries to avoid duplicate urls 2024-01-17 20:04:27 +01:00
Niko Abeler 30e54f99fb add links to rss 2023-09-04 20:23:02 +02:00
Niko Abeler 0bbcde6ff6 reset password command 2023-08-12 20:39:07 +02:00
Niko Abeler b0a6a0d417 better time format 2023-08-11 17:37:22 +02:00
Niko Abeler ad8cbbf556 sending webmentions again 2023-08-11 16:11:59 +02:00
Niko Abeler 08678e2697 move webmention stuff into util function (same code as v1) 2023-08-11 15:52:14 +02:00
Niko Abeler cd116b9a57 more generalized bus 2023-08-11 15:17:30 +02:00
Niko Abeler a89aa7ee27 upload images to instagram 2023-08-10 21:56:06 +02:00
Niko Abeler 9322e59b96 Prototype plugin 2023-08-09 21:56:56 +02:00
Niko Abeler d4351af8f1 tidy up 2023-08-09 21:45:56 +02:00
Niko Abeler 975761af2f test for interaction repo 2023-08-09 21:44:29 +02:00
Niko Abeler 55fb101ab5 Merge branch 'interactions' 2023-08-09 20:48:36 +02:00
Niko Abeler 653efcc487 deletion of webmentions 2023-08-09 20:48:16 +02:00
Niko Abeler 723a6000bf first retrieved webmention 2023-08-09 20:36:44 +02:00
Niko Abeler df5215d943 tests for incoming webmention 2023-08-09 20:10:51 +02:00
Niko Abeler 3f7b1bae50 disallow GPT in robots.txt 2023-08-09 19:16:17 +02:00
Niko Abeler 6ab9af2d53 WIP rebuilding incoming webmention from v1 code 2023-08-08 21:32:24 +02:00
Niko Abeler b1c46a86aa layouting webmention 2023-08-08 20:17:04 +02:00
Niko Abeler 5939bbd09d better entry ids 2023-08-08 19:42:11 +02:00
Niko Abeler 304db7ec03 no max width 2023-08-06 12:14:41 +02:00
Niko Abeler fb824ac07e smaller images 2023-08-06 12:08:46 +02:00
Niko Abeler 482a962351 link to admin page 2023-08-05 21:45:59 +02:00
Niko Abeler 74760707dd start refactoring forms. Allow list for recipe ingredients 2023-08-05 21:24:26 +02:00
Niko Abeler 349aa0dd5d draft list 2023-08-05 13:35:56 +02:00
Niko Abeler 70902af7e0 publishing in edit 2023-08-05 10:15:08 +02:00
Niko Abeler 42d960f185 allow draft publish + view drafts as author 2023-08-05 10:14:05 +02:00
Niko Abeler 9e64672f9c unpublish entry 2023-07-30 11:29:30 +02:00
Niko Abeler 080614e83f linking binary page 2023-07-27 22:04:00 +02:00
Niko Abeler ebca3e84f2 simple binary create, delete, list 2023-07-27 22:02:13 +02:00
Niko Abeler e998272f81 fix login redirect 2023-07-27 21:19:52 +02:00
Niko Abeler 7ead1ffe22 restructure admin area 2023-07-27 21:18:09 +02:00
Niko Abeler 0de18e89fc send 404 on messing binary 2023-07-27 20:56:26 +02:00
Niko Abeler 40117dab37 fix crash on missing file 2023-07-27 20:55:50 +02:00
Niko Abeler 5e77fdd33b WIP minimal AP 2023-07-26 21:28:57 +02:00
Niko Abeler 5f49754b71 deletion 2023-07-25 21:49:09 +02:00
Niko Abeler 34e4984af9 content type for media 2023-07-25 21:39:57 +02:00
Niko Abeler e35bf9af5d microformats improvement 2023-07-25 21:37:26 +02:00
Niko Abeler e628735ab2 edit feature 2023-07-25 21:31:03 +02:00
Niko Abeler a610515d5e sitemap 2023-07-25 19:10:37 +02:00
Niko Abeler ea9059f9f0 robots.txt 2023-07-25 18:59:25 +02:00
Niko Abeler 802e9a399e option to add html header and footer html 2023-07-24 21:27:51 +02:00
Niko Abeler a38ebb61a9 form to edit registered configs 2023-07-22 21:12:53 +02:00
Niko Abeler 68e8f84220 WIP activity pub PoC 2023-07-22 20:34:17 +02:00
Niko Abeler 0a4106be9c more RSS 2023-07-21 22:09:48 +02:00
Niko Abeler ea06b26c9f add self atom:link 2023-07-21 21:59:25 +02:00
Niko Abeler d736679418 improve rss 2023-07-21 21:40:32 +02:00
Niko Abeler e31dbeb18c trailing slash 2023-07-20 19:56:43 +02:00
Niko Abeler 128e38651d minimal xml from v1 2023-07-20 19:49:52 +02:00
Niko Abeler 838e949e57 tidy mod 2023-07-19 21:11:21 +02:00
h4kor 2932f2f16c Merge pull request 'v2' (#43) from v2 into main
Reviewed-on: #43
2023-07-19 19:09:19 +00:00
Niko Abeler 897f4094bf clean README 2023-07-19 21:07:36 +02:00
Niko Abeler 93184589bf markdown on note 2023-07-19 21:03:48 +02:00
Niko Abeler c24f2cb9db fix error with spaces in media name 2023-07-19 21:03:48 +02:00
Niko Abeler b60980b368 lists 2023-07-19 21:03:48 +02:00
Niko Abeler b488f9b032 refactor site config repo to general config repo 2023-07-19 21:03:48 +02:00
Niko Abeler 1bf817465c WIP release 2023-07-19 21:03:48 +02:00
Niko Abeler cc5ecff2f0 full import 2023-07-19 21:03:48 +02:00
Niko Abeler 485ccb9090 'finished' admin pages 2023-07-19 21:03:48 +02:00
Niko Abeler abbbbb4402 menu 2023-07-19 21:03:48 +02:00
Niko Abeler 5f897cb677 WIP site config forms 2023-07-19 21:03:48 +02:00
Niko Abeler e91128fd7e basic site config editing 2023-07-19 21:03:48 +02:00
Niko Abeler cecd94b296 WIP site config + copy existing design 2023-07-19 21:03:48 +02:00
Niko Abeler 3a1559584c siteconfig 2023-07-19 21:03:48 +02:00
Niko Abeler 033a6b4a7f rework to have worker as part of entry 2023-07-19 21:03:48 +02:00
Niko Abeler 687707a3e8 micro formats + html from owl 1 2023-07-19 21:03:48 +02:00
Niko Abeler 7c20b472f6 importer + markdown 2023-07-19 21:03:48 +02:00
Niko Abeler 9c30ff7877 WIP Import + refactor entry creation 2023-07-19 21:03:48 +02:00
Niko Abeler 088d41e8a2 option to set entry for binary file 2023-07-19 21:03:48 +02:00
Niko Abeler 9921b1f91e WIP more types 2023-07-19 21:03:48 +02:00
Niko Abeler 34119d5642 fix tests 2023-07-19 21:03:48 +02:00
Niko Abeler f513205cc3 listing entries 2023-07-19 21:03:48 +02:00
Niko Abeler 408bb88cb8 renaming and cleanup 2023-07-19 21:03:48 +02:00
Niko Abeler 723c94c576 renaming and cleanup 2023-07-19 21:03:48 +02:00
Niko Abeler eaacf70140 entry base 2023-07-19 21:03:48 +02:00
Niko Abeler 1514e7533c post entry 2023-07-19 21:03:48 +02:00
Niko Abeler e9e17ed263 first image \o/ 2023-07-19 21:03:48 +02:00
Niko Abeler a90bcaaa2d login 2023-07-19 21:03:48 +02:00
Niko Abeler 197629db9a secured editor 2023-07-19 21:03:48 +02:00
Niko Abeler bcf8ba4d9b upload files 2023-07-19 21:03:48 +02:00
Niko Abeler 9301790408 binary service 2023-07-19 21:03:48 +02:00
Niko Abeler 28fd4f1dc2 first entry created and displayed 2023-07-19 21:03:48 +02:00
Niko Abeler d3784b0337 editor list 2023-07-19 21:03:48 +02:00
Niko Abeler 8fc82b86a3 WIP first entry creation 2023-07-19 21:03:48 +02:00
Niko Abeler ff193f62e9 create 2023-07-19 21:03:48 +02:00
Niko Abeler bcacbf1e4d no more content 2023-07-19 21:03:48 +02:00
Niko Abeler 936a0a0d80 WIP html forms 2023-07-19 21:03:48 +02:00
Niko Abeler 1742728639 experiments for editor 2023-07-19 21:03:48 +02:00
Niko Abeler 5d97d87c9b type registery 2023-07-19 21:03:48 +02:00
Niko Abeler 229f5833e0 web app setup 2023-07-19 21:03:48 +02:00
Niko Abeler 7060c54989 init 2023-07-19 21:03:48 +02:00
Niko Abeler 4540797cce cleanup 2023-07-19 20:59:43 +02:00
Niko Abeler 10e0bde07b photo list 2023-06-18 19:40:35 +02:00
Niko Abeler 98e9a3b9d2 less rows for image 2023-01-14 11:33:05 +01:00
Niko Abeler 1e5ea053cc photo post type 2023-01-14 11:28:13 +01:00
Niko Abeler c7834b08d5 remove CRLF 2023-01-13 19:33:17 +01:00
Niko Abeler 659aaa6c8d fixed h-recipe 2023-01-12 21:22:15 +01:00
Niko Abeler 23d7afe9d4 recipe post 2023-01-12 21:21:29 +01:00
Niko Abeler a49deabf07 discard post types. Need a new approach for this 2022-12-15 19:23:28 +01:00
Niko Abeler ff50adac78 TODO note 2022-12-05 21:23:45 +01:00
Niko Abeler d783db98a2 clean up interface 2022-12-05 21:09:23 +01:00
Niko Abeler cad4897583 remove old new post function 2022-12-05 20:47:52 +01:00
Niko Abeler 1fefa2a140 more types 2022-12-05 20:31:48 +01:00
Niko Abeler b61e2ff50c split templates into one file per type 2022-12-05 19:51:42 +01:00
Niko Abeler 9209f227c4 removed unused title in Post 2022-12-05 19:11:48 +01:00
Niko Abeler bb5aca2bc1 more verbose test 2022-12-05 18:48:15 +01:00
Niko Abeler e2ad05cf0d Fixes #36 2022-12-05 18:46:00 +01:00
Niko Abeler a717164334 footer menu 2022-12-04 19:51:53 +01:00
Niko Abeler 8f2e2cd5f6 show wrong password hint 2022-12-04 19:48:46 +01:00
Niko Abeler 668eb658b2 links to posts and creating pages 2022-12-04 19:15:50 +01:00
Niko Abeler 51fd8cefe2 sending webmentions from editor 2022-12-04 19:03:09 +01:00
Niko Abeler 65fa4f6fb8 bookmark and reply in editor 2022-12-04 15:45:51 +01:00
Niko Abeler 0d5e6599cf fix styling of menu 2022-12-03 16:33:30 +01:00
Niko Abeler 1f0856602a definition of header menu 2022-12-03 16:21:51 +01:00
Niko Abeler c3563ce5ab smaller publish date 2022-12-01 20:00:42 +01:00
Niko Abeler b169d4f6c0 inlcude link to note in lists 2022-12-01 19:46:31 +01:00
Niko Abeler 29eea1d837 don't escape notes 2022-12-01 19:43:59 +01:00
Niko Abeler 48a11ab6d7 fix note rendering 2022-12-01 19:37:13 +01:00
Niko Abeler c67535c1d9 render notes fully 2022-12-01 19:32:49 +01:00
Niko Abeler 0db1c7a9ec Smaller note field 2022-12-01 19:20:13 +01:00
Niko Abeler 03f37c6d9e Create Note 2022-12-01 19:18:39 +01:00
Niko Abeler 18a593d2b1 allow definition of primary feed/list 2022-12-01 19:15:16 +01:00
Niko Abeler 58cd92a4b4 list definitions in user config 2022-12-01 19:10:08 +01:00
Niko Abeler 3fe828d31d tests for editor 2022-11-29 21:22:39 +01:00
Niko Abeler 521f7b16aa Refactoring Posts to allow multiple types 2022-11-29 20:58:34 +01:00
Niko Abeler e009505cb3 reject empty post_type 2022-11-29 20:47:33 +01:00
Niko Abeler f380f94043 WIP editor 2022-11-29 20:36:50 +01:00
h4kor b5dca6fa53 Merge pull request 'WIP micropub' (#28) from micropub into main
Reviewed-on: #28
2022-11-19 15:35:30 +00:00
Niko Abeler 0934aaa121 use 'note' as slug for untitled posts 2022-11-19 16:08:48 +01:00
Niko Abeler 396b84c9bb title substitute if no title set 2022-11-19 16:02:41 +01:00
Niko Abeler ecc12333e1 show error in h value 2022-11-19 15:55:38 +01:00
Niko Abeler 33ca1fba6f set date 2022-11-19 15:54:26 +01:00
Niko Abeler abc11e112f don't reject multiple acces tokens 2022-11-19 14:08:16 +01:00
Niko Abeler 1039a905bc smaller fixes + allow entries without title/name 2022-11-19 13:43:10 +01:00
Niko Abeler 2246cae3f7 WIP micropub 2022-11-08 21:22:02 +01:00
Niko Abeler fbf14704e7 refactor handler 2022-11-08 20:10:50 +01:00
Niko Abeler f377ce032a move response structs out of function 2022-11-08 20:07:21 +01:00
Niko Abeler b65230db88 error rendering 2022-11-08 19:57:20 +01:00
Niko Abeler b83cd2f73c split auth handler in own file 2022-11-08 19:27:54 +01:00
Niko Abeler 9c6a9cd499 split webmention into html and http 2022-11-08 19:24:09 +01:00
Niko Abeler 25fbed4d44 SameSite + httpOnly CSRF cookie 2022-11-07 21:24:39 +01:00
h4kor 5c3b6351d8 Merge pull request 'IndieAuth' (#25) from auth into master
Reviewed-on: #25
2022-11-07 19:38:20 +00:00
Niko Abeler 23d07d56f7 show requested scope 2022-11-07 20:35:05 +01:00
Niko Abeler 0dc200e0b0 show requested scope 2022-11-07 20:24:21 +01:00
Niko Abeler 3495822ec3 fix iss 2022-11-07 20:05:20 +01:00
Niko Abeler f2999ba53e iss param 2022-11-07 20:00:18 +01:00
Niko Abeler fc4f5a1623 check age of auth code 2022-11-07 19:53:32 +01:00
Niko Abeler 703531834d use .well-known 2022-11-07 19:50:13 +01:00
Niko Abeler 37936a450f indieauth-metadata 2022-11-07 19:44:10 +01:00
Niko Abeler 9389cc0266 first successfull token issuing (tested with quill.p3k.io) 2022-11-06 19:57:44 +01:00
Niko Abeler ff563c6f05 WIP access token 2022-11-06 19:38:27 +01:00
Niko Abeler fa30d4fd8e test for same host 2022-11-06 16:50:31 +01:00
Niko Abeler 1d793c325b allow redirect_uris from same host and scheme 2022-11-06 16:40:26 +01:00
Niko Abeler 4d5af131c2 PKCE implementation 2022-11-06 16:27:35 +01:00
Niko Abeler ae387f3d7d redirect uri verification 2022-11-06 14:36:37 +01:00
Niko Abeler 3975f08441 WIP redirect_uri verification 2022-11-06 14:17:14 +01:00
Niko Abeler f3f72d8111 CSRF 2022-11-05 21:17:31 +01:00
Niko Abeler 269262b381 fix auth page action 2022-11-05 20:24:28 +01:00
Niko Abeler 2246cad5df WIP auth codes and profile 2022-11-05 20:12:23 +01:00
Niko Abeler da9111c186 include request data in password form 2022-11-04 21:53:14 +01:00
Niko Abeler 1072f48e9f WIP Login Page 2022-11-03 21:22:55 +01:00
Niko Abeler 2acca40afe verifying password 2022-11-03 20:30:05 +01:00
Niko Abeler 7165e4c30a setting user password 2022-11-03 20:25:53 +01:00
Niko Abeler 76ef9c1fe6 Refactoring tests (done) 2022-11-03 19:23:13 +01:00
Niko Abeler 4aef1ca2ee WIP more cleaning up tests 2022-11-02 22:12:58 +01:00
Niko Abeler c92ab958a6 WIP clean up tests. Moving checks into assertions functions 2022-11-02 22:02:49 +01:00
Niko Abeler 73def1b477 warning 2022-11-01 22:00:01 +01:00
Niko Abeler 5f3978eaac updated docs 2022-11-01 21:57:51 +01:00
Niko Abeler 8c1d7fd8c7 General rel=me support. Resolves #13 2022-11-01 21:35:47 +01:00
Niko Abeler 94918b5b62 favicon. #19 2022-11-01 21:14:03 +01:00
Niko Abeler 7667190a9c added some OpenGraph meta tags 2022-10-19 21:14:31 +02:00
Niko Abeler 5abd9f4b4b removed unused data 2022-10-19 20:59:43 +02:00
Niko Abeler 5fb96ee6fd description in rss items 2022-10-14 21:06:26 +02:00
Niko Abeler edf74aa330 resolved warnings 2022-10-13 21:03:16 +02:00
Niko Abeler a42da82516 remove ioutil 2022-10-13 21:00:28 +02:00
Niko Abeler 0a3af85b02 nicer file names for posts 2022-10-13 20:58:16 +02:00
Niko Abeler bdb08657e3 refactoring yaml save/load into functions 2022-10-13 20:48:43 +02:00
Niko Abeler 9fe09af2e0 new posts as draft 2022-10-13 20:33:00 +02:00
Niko Abeler 09aed165eb adding photo to h-card in p-author 2022-10-10 21:26:24 +02:00
Niko Abeler 1c53244439 Send webmention to reply url 2022-10-10 21:06:33 +02:00
Niko Abeler 9056b22536 add reply-to url. #22 2022-10-10 20:59:06 +02:00
Niko Abeler 4cc69ff257 added author name. Resolves #21 2022-10-07 19:51:13 +02:00
Niko Abeler 1179263818 separate incoming and outgoing webmentions into different files. Allows one process to write outgoing without interfering with incoming 2022-10-07 16:49:26 +02:00
185 changed files with 9636 additions and 4631 deletions

46
.air.toml Normal file
View File

@ -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

3
.dockerignore Normal file
View File

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

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

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

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

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

7
.gitignore vendored
View File

@ -24,3 +24,10 @@ users/
.vscode/
*.swp
*.db
tmp/
venv/
*.pyc

View File

@ -1,3 +0,0 @@
{
"editor.formatOnSave": true,
}

View File

@ -1,10 +1,10 @@
##
## 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
@ -15,19 +15,21 @@ RUN go mod download
COPY . .
RUN go build -o ./out/owl ./cmd/owl
RUN CGO_ENABLED=1 GOOS=linux go build -o ./out/owl ./cmd/owl
##
## Run Container
##
FROM alpine:3.9
FROM alpine
RUN apk add ca-certificates
COPY --from=build /tmp/owl/out/ /bin/
# This container exposes port 8080 to the outside world
EXPOSE 8080
EXPOSE 3000
WORKDIR /owl
# Run the binary program produced by `go install`
ENTRYPOINT ["/bin/owl"]

106
README.md
View File

@ -2,82 +2,60 @@
# 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
A user has a collection of posts.
Each directory in the `/users/` directory of a repository is considered a user.
### User Directory structure
To run the web server use the command:
```
<user-name>/
\- 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
\- webmentions.yml
-- Used to track incoming and outgoing webmentions
\- 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
\- media/
-- All this files will be publicly available. To be used for general files
\- avatar.{png, jpg, jpeg, gif}
-- The avatar for the user
\- config.yml
-- Contains settings global to the user.
-- For example: page title and style options
owl web
```
### Post
The blog will run on port 3000 (http://localhost:3000)
Posts are Markdown files with a mandatory metadata head.
- 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.
To create a new account:
```
---
title: My new Post
date: 13 Aug 2022 17:07 UTC
aliases:
- /my/new/post
- /old_blog_path/
---
Actual post
owl new-author -u <name> -p <password>
```
#### webmentions.yml
To retrieve a list of all commands run:
```
incoming:
- source: https://example.com/post
title: Example Post
ApprovalStatus: ["", "approved", "rejected"]
retrieved_at: 2021-08-13T17:07:00Z
outgoing:
- target: https://example.com/post
supported: true
scanned_at: 2021-08-13T17:07:00Z
last_sent_at: 2021-08-13T17:07:00Z
owl -h
```
# Development
## Build
```
CGO_ENABLED=1 go build -o owl ./cmd/owl
```
For development with live reload use `air` ([has to be install first](https://github.com/cosmtrek/air))
## Tests
The project has two test suites; "unit tests" written in go and "end-to-end tests" written in python.
### Unit Tests
```
go test ./...
```
### End-to-End tests
- Start the docker compose setup in the `e2e_tests` directory.
- Install the python dependencies into a virtualenv
```
cd e2e_tests
python3 -m venv venv
. venv/bin/activate
pip install -r requirements.txt
```
- Run the e2e_tests with `pytest`

712
app/activity_pub_service.go Normal file
View File

@ -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
}

101
app/author_service.go Normal file
View File

@ -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
}

View File

@ -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)
}

37
app/binary_service.go Normal file
View File

@ -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)
}

39
app/config_register.go Normal file
View File

@ -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]
}

11
app/entry_register.go Normal file
View File

@ -0,0 +1,11 @@
package app
import (
"owl-blogs/domain/model"
)
type EntryTypeRegistry = TypeRegistry[model.Entry]
func NewEntryTypeRegistry() *EntryTypeRegistry {
return NewTypeRegistry[model.Entry]()
}

View File

@ -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)
}

127
app/entry_service.go Normal file
View File

@ -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
}

47
app/entry_service_test.go Normal file
View File

@ -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())
}

51
app/event_bus.go Normal file
View File

@ -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)
}
}
}

57
app/generic_register.go Normal file
View File

@ -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
}

View File

@ -1,4 +1,4 @@
package owl
package app
import (
"bytes"
@ -7,66 +7,18 @@ import (
"net/http"
"net/url"
"strings"
"time"
"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"`
}
func (webmention *WebmentionIn) UpdateWith(update WebmentionIn) {
if update.Title != "" {
webmention.Title = update.Title
}
if update.ApprovalStatus != "" {
webmention.ApprovalStatus = update.ApprovalStatus
}
if !update.RetrievedAt.IsZero() {
webmention.RetrievedAt = update.RetrievedAt
}
}
type WebmentionOut struct {
Target string `yaml:"target"`
Supported bool `yaml:"supported"`
ScannedAt time.Time `yaml:"scanned_at"`
LastSentAt time.Time `yaml:"last_sent_at"`
}
func (webmention *WebmentionOut) UpdateWith(update WebmentionOut) {
if update.Supported {
webmention.Supported = update.Supported
}
if !update.ScannedAt.IsZero() {
webmention.ScannedAt = update.ScannedAt
}
if !update.LastSentAt.IsZero() {
webmention.LastSentAt = update.LastSentAt
}
}
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 {
ParseHEntry(resp *http.Response) (ParsedHEntry, error)
ParseLinks(resp *http.Response) ([]string, error)
ParseLinksFromString(string) ([]string, error)
GetWebmentionEndpoint(resp *http.Response) (string, error)
GetRedirctUris(resp *http.Response) ([]string, error)
}
type OwlHttpClient = http.Client
type OwlHtmlParser struct{}
type ParsedHEntry struct {
Title string
}
@ -90,7 +42,7 @@ func readResponseBody(resp *http.Response) (string, error) {
return string(bodyBytes), nil
}
func (OwlHtmlParser) ParseHEntry(resp *http.Response) (ParsedHEntry, error) {
func ParseHEntry(resp *http.Response) (ParsedHEntry, error) {
htmlStr, err := readResponseBody(resp)
if err != nil {
return ParsedHEntry{}, err
@ -137,15 +89,15 @@ func (OwlHtmlParser) ParseHEntry(resp *http.Response) (ParsedHEntry, error) {
return findHFeed(doc)
}
func (OwlHtmlParser) ParseLinks(resp *http.Response) ([]string, error) {
func ParseLinks(resp *http.Response) ([]string, error) {
htmlStr, err := readResponseBody(resp)
if err != nil {
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))
if err != nil {
return make([]string, 0), err
@ -170,7 +122,7 @@ func (OwlHtmlParser) ParseLinksFromString(htmlStr string) ([]string, error) {
return findLinks(doc)
}
func (OwlHtmlParser) GetWebmentionEndpoint(resp *http.Response) (string, error) {
func GetWebmentionEndpoint(resp *http.Response) (string, error) {
//request url
requestUrl := resp.Request.URL
@ -243,3 +195,73 @@ func (OwlHtmlParser) GetWebmentionEndpoint(resp *http.Response) (string, error)
}
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
}

View File

@ -0,0 +1,11 @@
package app
import (
"owl-blogs/domain/model"
)
type InteractionTypeRegistry = TypeRegistry[model.Interaction]
func NewInteractionTypeRegistry() *InteractionTypeRegistry {
return NewTypeRegistry[model.Interaction]()
}

View File

@ -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()
}

13
app/owlhttp/interface.go Normal file
View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

25
app/utils.go Normal file
View File

@ -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]
}
}

151
app/webmention_service.go Normal file
View File

@ -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)
}

217
app/webmention_test.go Normal file
View File

@ -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)
// }
// }
// }

29
cmd/owl/create_author.go Normal file
View File

@ -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)
},
}

136
cmd/owl/editor_test.go Normal file
View File

@ -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")
}

204
cmd/owl/import_v1.go Normal file
View File

@ -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)
}
}
},
}

View File

@ -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)
}
},
}

View File

@ -3,11 +3,19 @@ package main
import (
"fmt"
"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"
)
var repoPath string
const DbPath = "owlblogs.db"
var rootCmd = &cobra.Command{
Use: "owl",
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.")
rootCmd.PersistentFlags().StringVar(&user, "user", "", "Username. Required for some commands.")
interactionRegister := app.NewInteractionTypeRegistry()
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,
)
}

24
cmd/owl/media_test.go Normal file
View File

@ -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)
}

View File

@ -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)
}
},
}

View File

@ -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)
}
},
}

26
cmd/owl/reset_password.go Normal file
View File

@ -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)
},
}

View File

@ -1,17 +1,13 @@
package main
import (
web "h4kor/owl-blogs/cmd/owl/web"
"owl-blogs/infra"
"github.com/spf13/cobra"
)
var port int
func init() {
rootCmd.AddCommand(webCmd)
webCmd.PersistentFlags().IntVar(&port, "port", 8080, "Port to use")
}
var webCmd = &cobra.Command{
@ -19,6 +15,7 @@ var webCmd = &cobra.Command{
Short: "Start the web server",
Long: `Start the web server`,
Run: func(cmd *cobra.Command, args []string) {
web.StartServer(repoPath, port)
db := infra.NewSqliteDB(DbPath)
App(db).Run()
},
}

View File

@ -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)
}
}

View File

@ -1,271 +0,0 @@
package web
import (
"fmt"
"h4kor/owl-blogs"
"net/http"
"net/url"
"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
}
tryAlias := func(target string) *owl.Post {
parsedTarget, _ := url.Parse(target)
aliases, _ := repo.PostAliases()
fmt.Printf("aliases %v", aliases)
fmt.Printf("parsedTarget %v", parsedTarget)
if _, ok := aliases[parsedTarget.Path]; ok {
return aliases[parsedTarget.Path]
}
return nil
}
var aliasPost *owl.Post
parts := strings.Split(target[0], "/")
if len(parts) < 2 {
aliasPost = tryAlias(target[0])
if aliasPost == nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Not found"))
return
}
}
postId := parts[len(parts)-2]
foundPost, err := user.GetPost(postId)
if err != nil && aliasPost == nil {
aliasPost = tryAlias(target[0])
if aliasPost == nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Post not found"))
return
}
}
if aliasPost != nil {
foundPost = *aliasPost
}
err = foundPost.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 userMediaHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
filepath := ps.ByName("filepath")
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
filepath = path.Join(user.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"))
}
}

View File

@ -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())
}
}

View File

@ -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)
}
}

View File

@ -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())
}
}

View File

@ -1,59 +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.GET("/user/:user/media/*filepath", userMediaHandler(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.POST("/user/:user/webmention/", userWebmentionHandler(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.GET("/media/*filepath", userMediaHandler(repo))
router.GET("/index.xml", userRSSHandler(repo))
router.GET("/posts/:post/", postHandler(repo))
router.GET("/posts/:post/media/*filepath", postMediaHandler(repo))
router.POST("/webmention/", userWebmentionHandler(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)
}

View File

@ -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())
}
}

View File

@ -1,187 +0,0 @@
package web_test
import (
"h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"net/http"
"net/http/httptest"
"net/url"
"os"
"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)
}
func TestAcceptWebmentionForAlias(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)
target := "https://example.com/foo/bar"
source := "https://example.com"
rr, err := setupWebmentionTest(repo, user, target, source)
if err != nil {
t.Fatal(err)
}
assertStatus(t, rr, http.StatusAccepted)
}

View File

@ -1,99 +0,0 @@
package main
import (
"h4kor/owl-blogs"
"sync"
"github.com/spf13/cobra"
)
var postId string
func init() {
rootCmd.AddCommand(webmentionCmd)
webmentionCmd.Flags().StringVar(
&postId, "post", "",
"specify the post to send webmentions for. Otherwise, all posts will be checked.",
)
}
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
}
}
processPost := func(user owl.User, post *owl.Post) error {
println("Webmentions for post: ", post.Title())
err := post.ScanForLinks()
if err != nil {
println("Error scanning post for links: ", err.Error())
return err
}
webmentions := post.OutgoingWebmentions()
println("Found ", len(webmentions), " links")
wg := sync.WaitGroup{}
wg.Add(len(webmentions))
for _, webmention := range webmentions {
go func(webmention owl.WebmentionOut) {
defer wg.Done()
sendErr := post.SendWebmention(webmention)
if sendErr != nil {
println("Error sending webmentions: ", sendErr.Error())
} else {
println("Webmention sent to ", webmention.Target)
}
}(webmention)
}
wg.Wait()
return nil
}
for _, user := range users {
if postId != "" {
// send webmentions for a specific post
post, err := user.GetPost(postId)
if err != nil {
println("Error getting post: ", err.Error())
return
}
processPost(user, &post)
return
}
posts, err := user.Posts()
if err != nil {
println("Error getting posts: ", err.Error())
}
for _, post := range posts {
processPost(user, post)
}
}
},
}

26
config/config.go Normal file
View File

@ -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{}
}

View File

@ -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
}

8
domain/model/author.go Normal file
View File

@ -0,0 +1,8 @@
package model
type Author struct {
Name string
PasswordHash string
FullUrl string
AvatarUrl string
}

View File

@ -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
}

View File

@ -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())
}

View File

@ -0,0 +1,5 @@
package model
type BinaryStorageInterface interface {
Create(name string, file []byte) (*BinaryFile, error)
}

70
domain/model/entry.go Normal file
View File

@ -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
}

View File

@ -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)
}
}

20
domain/model/form_data.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -0,0 +1,6 @@
package model
type SiteConfigInterface interface {
GetSiteConfig() (SiteConfig, error)
UpdateSiteConfig(cfg SiteConfig) error
}

49
e2e_tests/conftest.py Normal file
View File

@ -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"]

View File

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

View File

@ -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

View File

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

View File

@ -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")

View File

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

View File

@ -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

View File

102
e2e_tests/tests/fixtures.py Normal file
View File

@ -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}"

View File

@ -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

View File

@ -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

View File

@ -1,6 +0,0 @@
package owl
import "embed"
//go:embed embed/*
var embed_files embed.FS

View File

@ -1,79 +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;
}
.avatar {
float: left;
margin-right: 1rem;
}
.header {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-items: flex-start;
}
.header-title {
order: 0;
}
.header-profile {
order: 1;
}
hgroup h2 a { color: inherit; }
</style>
</head>
<body>
<header>
<div class="container header h-card">
<hgroup class="header-title">
<h2><a class="p-name u-url" href="{{ .User.UrlPath }}">{{ .UserConfig.Title }}</a></h2>
<h3 class="p-note">{{ .UserConfig.SubTitle }}</h3>
</hgroup>
<div class="header-profile">
{{ if .User.AvatarUrl }}
<img class="u-logo avatar" src="{{ .User.AvatarUrl }}" alt="{{ .UserConfig.Title }}" width="100" height="100" />
{{ end }}
<div style="float: right; list-style: none;">
{{ if .UserConfig.TwitterHandle}}
<li><a href="https://twitter.com/{{.UserConfig.TwitterHandle}}" rel="me">@{{.UserConfig.TwitterHandle}} on Twitter</a>
</li>
{{ end }}
{{ if .UserConfig.GitHubHandle}}
<li><a href="https://github.com/{{.UserConfig.GitHubHandle}}" rel="me">@{{.UserConfig.GitHubHandle}} on GitHub</a>
</li>
{{ end }}
</div>
</div>
</div>
</header>
<main class="container">
{{ .Content }}
</main>
<footer class="container">
</footer>
</body>
</html>

View File

@ -1,5 +0,0 @@
<ul>
{{ range .UserLinks }}
<li><a href="{{.Href}}">{{.Text}}</a></li>
{{ end }}
</ul>

View File

@ -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

View File

@ -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.FormattedDate}}
</time>
</small>
</hgroup>
</div>
<hr>
{{end}}
</div>

View File

@ -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.FormattedDate}}
</time>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
</small>
</hgroup>
<hr>
<br>
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

View File

@ -1,9 +0,0 @@
{{range .}}
<ul>
<li>
<a href="{{ .UrlPath }}">
{{ .Name }}
</a>
</li>
</ul>
{{end}}

50
entry_types/article.go Normal file
View File

@ -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)
}

52
entry_types/bookmark.go Normal file
View File

@ -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)
}

79
entry_types/image.go Normal file
View File

@ -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)
}

48
entry_types/note.go Normal file
View File

@ -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)
}

50
entry_types/page.go Normal file
View File

@ -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)
}

64
entry_types/recipe.go Normal file
View File

@ -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)
}

52
entry_types/reply.go Normal file
View File

@ -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
View File

@ -1,16 +1,50 @@
module h4kor/owl-blogs
module owl-blogs
go 1.18
go 1.22
require (
github.com/julienschmidt/httprouter v1.3.0
github.com/spf13/cobra v1.5.0
github.com/yuin/goldmark v1.4.13
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b
github.com/Davincible/goinsta/v3 v3.2.6
github.com/go-ap/activitypub v0.0.0-20240408091739-ba76b44c2594
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73
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
)
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/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
View File

@ -1,19 +1,109 @@
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:cliQ4HHsCo6xi2oWZYKWW4bly/Ory9FuTpFPRxj/mAg=
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs=
github.com/Davincible/goinsta/v3 v3.2.6 h1:+lNIWU6NABWd2VSGe83UQypnef+kzWwjmfgGihPbwD8=
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/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
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/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY=
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

33
importer/config.go Normal file
View File

@ -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"`
}

238
importer/utils.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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"))
}

View File

@ -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
}

View File

@ -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)
}

161
infra/entry_repository.go Normal file
View File

@ -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
}

View File

@ -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())
}

View File

@ -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
}

View File

@ -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)
}

5
infra/http.go Normal file
View File

@ -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