jekyll-activity-pub 0.1.0rc0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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