jekyll-activity-pub 0.1.0rc0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jekyll/hooks'
4
+
5
+ module Jekyll
6
+ module ActivityPub
7
+ # Container for common tools
8
+ module Helper
9
+ include Jekyll::Filters::URLFilters
10
+
11
+ # Some filters needs a Liquid-like context
12
+ StubContext = Struct.new(:registers, keyword_init: true)
13
+
14
+ # Renders the data hash as a stringified JSON
15
+ #
16
+ # @return [String]
17
+ def content
18
+ data.to_json
19
+ end
20
+
21
+ # JSONify this object
22
+ def to_json(*args)
23
+ data.to_json(*args)
24
+ end
25
+
26
+ # There's no excerpt to be generated
27
+ #
28
+ # @return [Boolean]
29
+ def generate_excerpt?
30
+ false
31
+ end
32
+
33
+ # Trigger hooks
34
+ #
35
+ # @param :hook_name [Symbol]
36
+ # @param :args [any]
37
+ # @return [nil]
38
+ def trigger_hooks(hook_name, *args)
39
+ Jekyll::Hooks.trigger hook_owner, hook_name.to_sym, self, *args
40
+ end
41
+
42
+ # Simpler version of ActiveSupport::Inflector#underscore
43
+ #
44
+ # https://stackoverflow.com/a/1510078
45
+ #
46
+ # @see Jekyll::Convertible
47
+ # @return [Symbol]
48
+ def hook_owner
49
+ @hook_owner ||= self.class.name.split('::').last.gsub(/(.)([A-Z])/, '\1_\2').downcase.to_sym
50
+ end
51
+
52
+ alias type hook_owner
53
+
54
+ private
55
+
56
+ # Is Liquid on strict mode?
57
+ #
58
+ # @return [Boolean]
59
+ def strict?
60
+ site.config.dig('liquid', 'strict_variables')
61
+ end
62
+
63
+ # Returns site name, for use as a username. "site" by default.
64
+ #
65
+ # @return [String]
66
+ def username
67
+ @username ||= site.config.dig('activity_pub', 'username')
68
+ @username ||= hostname&.split('.', 2)&.first
69
+ @username ||= 'site'
70
+ end
71
+
72
+ # Return hostname
73
+ #
74
+ # @return [String]
75
+ def hostname
76
+ return @hostname if defined? @hostname
77
+
78
+ @hostname = site.config.dig('activity_pub', 'hostname')
79
+ @hostname ||= site.config['hostname']
80
+ @hostname ||= Addressable::URI.parse(site.config['url']).hostname if site.config['url']
81
+ @hostname ||= File.read(cname_file).strip if cname_file?
82
+
83
+ if @hostname.nil? || @hostname.empty?
84
+ raise ArgumentError, 'Site must have a hostname' if strict?
85
+
86
+ Jekyll.logger.warn 'ActivityPub:', 'Site must have a hostname'
87
+
88
+ end
89
+
90
+ @hostname
91
+ end
92
+
93
+ # Site uses CNAME file
94
+ #
95
+ # @return [String]
96
+ def cname_file
97
+ @cname_file ||= site.in_source_dir('CNAME')
98
+ end
99
+
100
+ # @return [Boolean]
101
+ def cname_file?
102
+ File.exist?(cname_file)
103
+ end
104
+
105
+ # Detects locale
106
+ #
107
+ # @return [String]
108
+ def locale
109
+ return @locale if defined? @locale
110
+
111
+ @locale = site.config['locale']
112
+ @locale ||= ENV['LANG']&.split('.', 2)&.first
113
+
114
+ @locale = @locale&.tr('_', '-')
115
+ end
116
+
117
+ # Finds the value of a text field amongst options
118
+ #
119
+ # @params :hash [Hash] The haystack
120
+ # @param :args [String,Array] A combination of paths to find
121
+ # a value
122
+ # @return [Any, nil]
123
+ def find_best_value_for(hash, *args)
124
+ raise ArgumentError, 'First argument must be hash' unless hash.is_a? Hash
125
+
126
+ field = args.find do |f|
127
+ !hash.dig(*f).nil? && !hash.dig(*f).empty?
128
+ rescue TypeError
129
+ false
130
+ end
131
+
132
+ hash.dig(*field) if field
133
+ end
134
+
135
+ # Raise an exception if the value is required but empty
136
+ #
137
+ # @param :value [String,nil]
138
+ # @param :exception [Object]
139
+ def value_is_required!(value, exception)
140
+ raise exception if value.nil? || value.empty?
141
+ rescue exception => e
142
+ raise if strict?
143
+
144
+ Jekyll.logger.warn 'ActivityPub:', e.message
145
+ end
146
+
147
+ # Generates an absolute URL if not empty
148
+ #
149
+ # @param :url [String,nil]
150
+ # @return [nil,String]
151
+ def conditional_absolute_url(url)
152
+ return if url.nil? || url.empty?
153
+
154
+ absolute_url url
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jekyll/page_without_a_file'
4
+ require_relative 'helper'
5
+
6
+ module Jekyll
7
+ module ActivityPub
8
+ # Host meta
9
+ #
10
+ # https://www.rfc-editor.org/rfc/rfc6415
11
+ class HostMeta < Jekyll::PageWithoutAFile
12
+ include Helper
13
+
14
+ # Initialize with default data
15
+ #
16
+ # @param :site [Jekyll::Site]
17
+ # @param :base [String]
18
+ # @param :dir [String]
19
+ # @param :name [String]
20
+ def initialize(site, webfinger, base = '', dir = '.well-known', name = 'host-meta')
21
+ super(site, base, dir, name)
22
+
23
+ @webfinger = webfinger
24
+ @context = StubContext.new(registers: { site: site })
25
+
26
+ trigger_hooks :post_init
27
+ end
28
+
29
+ def permalink
30
+ '.well-known/host-meta'
31
+ end
32
+
33
+ # @return [String]
34
+ def content
35
+ @content ||=
36
+ <<~CONTENT
37
+ <?xml version="1.0" encoding="UTF-8"?>
38
+ <XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
39
+ <Link rel="lrdd" template="#{absolute_url @webfinger.url}"/>
40
+ </XRD>
41
+ CONTENT
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+
5
+ module Jekyll
6
+ module ActivityPub
7
+ # Represents an Image
8
+ class Image
9
+ include Helper
10
+
11
+ attr_reader :data
12
+
13
+ # Initialize with default data
14
+ #
15
+ # @param :site [Jekyll::Site]
16
+ # @param :path [Path]
17
+ # @param :description [String]
18
+ def initialize(site, path, description = nil)
19
+ @context = StubContext.new(registers: { site: site })
20
+
21
+ @data = {
22
+ 'type' => 'Image',
23
+ 'mediaType' => "image/#{File.extname(path).sub('.', '')}",
24
+ 'url' => absolute_url(path),
25
+ 'name' => description.to_s
26
+ }
27
+
28
+ trigger_hooks :post_init
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'httparty'
5
+ require 'distributed_press/v1/social/client'
6
+ require_relative 'errors'
7
+ require_relative 'create'
8
+ require_relative 'update'
9
+ require_relative 'delete'
10
+
11
+ module Jekyll
12
+ module ActivityPub
13
+ # Long term store for notifications.
14
+ #
15
+ # Needs to be a singleton so we can use the same data across all of
16
+ # Jekyll's build process.
17
+ class Notifier
18
+ # An struct that responds to a #url method
19
+ PseudoObject = Struct.new(:url, keyword_init: true)
20
+
21
+ class << self
22
+ # Set the site and initialize data
23
+ #
24
+ # @param :site [Jekyll::Site]
25
+ # @return [Jekyll::Site]
26
+ def site=(site)
27
+ @@site = site.tap do |s|
28
+ s.data['activity_pub'] ||= {}
29
+ s.data['activity_pub']['notifications'] ||= {}
30
+ end
31
+ end
32
+
33
+ # Save the public key URL for later
34
+ #
35
+ # @param :url [String]
36
+ def public_key_url=(url)
37
+ data['public_key_url'] = url
38
+ end
39
+
40
+ # Public key URL
41
+ #
42
+ # @return [String,nil]
43
+ def public_key_url
44
+ data['public_key_url']
45
+ end
46
+
47
+ def actor=(actor)
48
+ data['actor'] = actor
49
+ end
50
+
51
+ def actor
52
+ data['actor']
53
+ end
54
+
55
+ def actor_url=(url)
56
+ data['actor_url'] = url
57
+ end
58
+
59
+ def actor_url
60
+ data['actor_url']
61
+ end
62
+
63
+ # Send notifications
64
+ #
65
+ # 1. Wait for public key propagation
66
+ # 2. Create/update inbox
67
+ # 3. Send create, update, and delete
68
+ def notify!
69
+ # TODO: request several times with a timeout
70
+ response = HTTParty.get(public_key_url)
71
+
72
+ unless response.ok?
73
+ raise NotificationError,
74
+ "Could't fetch public key (#{response.code}: #{response.message})"
75
+ end
76
+
77
+ unless client.private_key.compare? OpenSSL::Pkey::RSA.new(response.body)
78
+ raise NotificationError, "Public key at #{public_key_url} differs from local version"
79
+ end
80
+
81
+ base_endpoint = "/v1/#{actor}/"
82
+ outbox_endpoint = "#{base_endpoint}/outbox/"
83
+ actor_object = PseudoObject.new(url: actor_url)
84
+
85
+ # Create inbox
86
+ # TODO: Send keypair
87
+ client.post(endpoint: base_endpoint, body: {})
88
+
89
+ # Remove notifications already performed and notify
90
+ data['notifications'].reject do |object_url, status|
91
+ done? object_url, status
92
+ end.each do |object_url, status|
93
+ process_object(outbox_endpoint, actor_object, PseudoObject.new(url: object_url), status)
94
+ end
95
+
96
+ # Store everything for later
97
+ save
98
+ end
99
+
100
+ # @return [Jekyll::Site]
101
+ def site
102
+ @@site
103
+ end
104
+
105
+ # Return data
106
+ #
107
+ # @return [Hash]
108
+ def data
109
+ @@data ||= site.data['activity_pub']
110
+ end
111
+
112
+ # Removes an activity if it was previously created
113
+ #
114
+ # @param :path [String]
115
+ # @return [Hash]
116
+ def delete(path)
117
+ action(path, 'delete') if exist? path
118
+ end
119
+
120
+ # Updates an activity if it was previously created
121
+ #
122
+ # @param :path [String]
123
+ # @return [Hash]
124
+ def update(path)
125
+ action(path, 'update') if exist? path
126
+ end
127
+
128
+ # Creates an activity
129
+ #
130
+ # @param :path [String]
131
+ # @return [Hash]
132
+ def create(path)
133
+ action(path, 'create')
134
+ end
135
+
136
+ # Check if activity existed
137
+ #
138
+ # @param :path [String]
139
+ # @return [Boolean]
140
+ def exist?(path)
141
+ data['notifications'].key? path_relative_to_dest(path)
142
+ end
143
+
144
+ # Stores data back to a file and optionally commits it
145
+ #
146
+ # @return [nil]
147
+ def save
148
+ # TODO: Send warning if CI is detected
149
+ Jekyll.logger.info 'ActivityPub:', "Saving data to #{relative_path}"
150
+
151
+ FileUtils.mkdir_p(File.dirname(path))
152
+
153
+ File.open(path, 'w') do |f|
154
+ f.flock(File::LOCK_EX)
155
+ f.rewind
156
+ f.write(YAML.dump(data))
157
+ f.flush
158
+ f.truncate(f.pos)
159
+ end
160
+
161
+ if ENV['JEKYLL_ENV'] == 'production' && site.respond_to?(:repository)
162
+ site.staged_files << relative_path
163
+ site.repository.commit 'ActivityPub'
164
+ end
165
+
166
+ nil
167
+ end
168
+
169
+ # Returns the path for the storage
170
+ #
171
+ # @return [String]
172
+ def path
173
+ @@path ||= site.in_source_dir(site.config['data_dir'], 'activity_pub.yml')
174
+ end
175
+
176
+ # Storage path relative to site source
177
+ #
178
+ # @return [String]
179
+ def relative_path
180
+ @@relative_path ||= Pathname.new(path).relative_path_from(site.source).to_s
181
+ end
182
+
183
+ private
184
+
185
+ # Finds the private key path on config
186
+ #
187
+ # @return [String, nil]
188
+ def private_key_path
189
+ @@private_key_path ||= site.config['activity_pub_private_key']
190
+ end
191
+
192
+ # Returns the private key
193
+ #
194
+ # @return [String, nil]
195
+ def private_key
196
+ @@private_key ||= File.read private_key_path
197
+ rescue StandardError
198
+ Jekyll.logger.warn 'ActivityPub:', 'There\'s an issue with your private key'
199
+ raise
200
+ end
201
+
202
+ # @return [Hash]
203
+ def config
204
+ @@config ||= site.config['activity_pub'] || {}
205
+ end
206
+
207
+ def client
208
+ @@client ||= DistributedPress::V1::Social::Client.new(
209
+ private_key_pem: private_key,
210
+ url: config['url'],
211
+ public_key_url: public_key_url
212
+ )
213
+ end
214
+
215
+ # Run action
216
+ #
217
+ # @param :path [String]
218
+ # @param :action [String]
219
+ # @return [Hash]
220
+ def action(path, action)
221
+ path = path_relative_to_dest(path)
222
+
223
+ data['notifications'][path] ||= {}
224
+ data['notifications'][path]['action'] = action.to_s
225
+ end
226
+
227
+ # Paths are relative to site destination
228
+ #
229
+ # @param :path [String]
230
+ # @return [String]
231
+ def path_relative_to_dest(path)
232
+ Pathname.new(site.in_dest_dir(path)).relative_path_from(site.dest).to_s
233
+ end
234
+
235
+ # Detects if an action was already done
236
+ def done?(url, status)
237
+ (status['action'] == 'done').tap do |done|
238
+ Jekyll.logger.debug('ActivityPub:', "Skipping notification for #{url}") if done
239
+ end
240
+ end
241
+
242
+ # Turns an object into an activity and notifies outbox
243
+ #
244
+ # @param :endpoint [String]
245
+ # @param :actor [PseudoObject]
246
+ # @param :object [PseudoObject]
247
+ # @param :status [Hash]
248
+ # @return [nil]
249
+ def process_object(endpoint, actor, object, status)
250
+ action = status['action']
251
+ activity = Object.const_get(action.capitalize).new(site, actor, object)
252
+
253
+ if (response = client.post(endpoint: endpoint, body: activity)).ok?
254
+ status['action'] = 'done'
255
+ status["#{action}d_at"] = Time.now.to_i
256
+ else
257
+ Jekyll.logger.warn 'ActivityPub:',
258
+ "Couldn't perform #{action} for #{object_url} (#{response.code}: #{response.message})"
259
+ end
260
+
261
+ nil
262
+ rescue NameError
263
+ Jekyll.logger.warn 'ActivityPub:', "Action \"#{action}\" for #{url} unrecognized, ignoring."
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jekyll/page'
4
+ require_relative 'helper'
5
+ require_relative 'ordered_collection_page'
6
+
7
+ module Jekyll
8
+ module ActivityPub
9
+ # An ordered collection of activities
10
+ class OrderedCollection < Jekyll::Page
11
+ include Helper
12
+
13
+ # Initialize with default data
14
+ #
15
+ # @param :site [Jekyll::Site]
16
+ # @param :base [String]
17
+ # @param :dir [String]
18
+ # @param :name [String]
19
+ def initialize(site, base = '', dir = '', name = 'ordered_collection.jsonld')
20
+ @context = StubContext.new(registers: { site: site })
21
+
22
+ super
23
+
24
+ trigger_hooks :post_init
25
+ end
26
+
27
+ def read_yaml(*)
28
+ self.data = {
29
+ '@context' => 'https://www.w3.org/ns/activitystreams',
30
+ 'id' => absolute_url(url),
31
+ 'type' => 'OrderedCollection',
32
+ 'totalItems' => 0,
33
+ 'orderedItems' => []
34
+ }
35
+ end
36
+
37
+ alias original_content content
38
+
39
+ # Paginates the collection if it has too many activities
40
+ #
41
+ # @return [String]
42
+ def content
43
+ @content ||=
44
+ begin
45
+ order_items!
46
+
47
+ return original_content unless always_paginate? || paginable?
48
+ return original_content if data['orderedItems'].empty?
49
+
50
+ paged_data = data.dup
51
+ pages = paginate(paged_data.delete('orderedItems'))
52
+
53
+ assign_links! pages
54
+
55
+ paged_data['first'] = absolute_url(pages.first.url)
56
+ paged_data['last'] = absolute_url(pages.last.url)
57
+
58
+ paged_data.to_json
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ # Items per page
65
+ #
66
+ # @return [Integer]
67
+ def items_per_page
68
+ @items_per_page ||=
69
+ begin
70
+ from_config = site.config.dig('activity_pub', 'items_per_page').to_i
71
+ if from_config.positive?
72
+ from_config
73
+ else
74
+ Jekyll.logger.warn 'ActivityPub:', 'Items per page option empty or not valid, using 20 items per page'
75
+ 20
76
+ end
77
+ end
78
+ end
79
+
80
+ # Force pagination
81
+ #
82
+ # @return [Boolean]
83
+ def always_paginate?
84
+ !!site.config.dig('activity_pub', 'always_paginate')
85
+ end
86
+
87
+ # Item count is higher than items per page
88
+ #
89
+ # @return [Boolean]
90
+ def paginable?
91
+ data['orderedItems'].size > items_per_page
92
+ end
93
+
94
+ # Generate pages
95
+ #
96
+ # @param :items [Array]
97
+ # @return [Array]
98
+ def paginate(items)
99
+ [].tap do |pages|
100
+ total_pages = items.count / items_per_page
101
+
102
+ items.each_slice(items_per_page).each_with_index do |paged_items, i|
103
+ OrderedCollectionPage.new(site, self, nil, basename, "#{total_pages - i}.jsonld").tap do |page|
104
+ page.data['orderedItems'] = paged_items
105
+ site.pages << page
106
+ pages << page
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ # Assign previous and next links between pages
113
+ #
114
+ # @param :pages [Array]
115
+ # @return [nil]
116
+ def assign_links!(pages)
117
+ pages.reduce do |previous, current|
118
+ current.data['prev'] = absolute_url(previous.url)
119
+ previous.data['next'] = absolute_url(current.url)
120
+
121
+ current
122
+ end
123
+
124
+ nil
125
+ end
126
+
127
+ # Sort items
128
+ #
129
+ # @return [nil]
130
+ def order_items!
131
+ data['orderedItems'].sort! do |a, b|
132
+ b.data['published'] <=> a.data['published']
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jekyll/page'
4
+ require_relative 'helper'
5
+
6
+ module Jekyll
7
+ module ActivityPub
8
+ # A collection of activities
9
+ class OrderedCollectionPage < Jekyll::Page
10
+ include Helper
11
+
12
+ attr_reader :outbox
13
+
14
+ # Initialize with default data
15
+ #
16
+ # @param :site [Jekyll::Site]
17
+ # @param :outbox [Jekyll::ActivityPub::Outbox]
18
+ # @param :base [String]
19
+ # @param :dir [String]
20
+ # @param :name [String]
21
+ def initialize(site, outbox, base = '', dir = 'outbox', name = 'page.jsonld')
22
+ @context = StubContext.new(registers: { site: site })
23
+ @outbox = outbox
24
+
25
+ super(site, base, dir, name)
26
+
27
+ trigger_hooks :post_init
28
+ end
29
+
30
+ def read_yaml(*)
31
+ self.data = {
32
+ '@context' => 'https://www.w3.org/ns/activitystreams',
33
+ 'id' => absolute_url(url),
34
+ 'type' => 'OrderedCollectionPage',
35
+ 'partOf' => absolute_url(outbox.url),
36
+ 'orderedItems' => []
37
+ }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ordered_collection'
4
+
5
+ module Jekyll
6
+ module ActivityPub
7
+ # A collection of activities
8
+ class Outbox < OrderedCollection
9
+ # Initialize with default data
10
+ #
11
+ # @param :site [Jekyll::Site]
12
+ # @param :actor [Jekyll::ActivityPub::Actor]
13
+ # @param :base [String]
14
+ # @param :dir [String]
15
+ # @param :name [String]
16
+ def initialize(site, actor, base = '', dir = '', name = 'outbox.jsonld')
17
+ super(site, base, dir, name)
18
+
19
+ actor.data['outbox'] = absolute_url(url)
20
+
21
+ trigger_hooks :post_init
22
+ end
23
+ end
24
+ end
25
+ end