Commit 3cc47beb authored by Eugen Rochko's avatar Eugen Rochko

Refactored generation of unique tags, URIs and object URLs into own classes,

as well as formatting of content
parent 735b4cc6
module ApplicationHelper
def unique_tag(date, id, type)
"tag:#{Rails.configuration.x.local_domain},#{date.strftime('%Y-%m-%d')}:objectId=#{id}:objectType=#{type}"
end
def unique_tag_to_local_id(tag, expected_type)
matches = Regexp.new("objectId=([\\d]+):objectType=#{expected_type}").match(tag)
return matches[1] unless matches.nil?
end
def local_id?(id)
id.start_with?("tag:#{Rails.configuration.x.local_domain}")
end
def content_for_status(actual_status)
if actual_status.local?
linkify(actual_status)
else
sanitize(actual_status.content, tags: %w(a br p), attributes: %w(href rel))
end
end
def account_from_mentions(search_string, mentions)
mentions.each { |x| return x.account if x.account.acct.eql?(search_string) }
nil
# If that was unsuccessful, try fetching user from db separately
# But this shouldn't ever happen if the mentions were created correctly!
# username, domain = search_string.split('@')
# if domain == Rails.configuration.x.local_domain
# account = Account.find_local(username)
# else
# account = Account.find_remote(username, domain)
# end
# account
end
def linkify(status)
auto_link(HTMLEntities.new.encode(status.text), link: :urls, html: { rel: 'nofollow noopener' }).gsub(Account::MENTION_RE) do |m|
account = account_from_mentions(Account::MENTION_RE.match(m)[1], status.mentions)
unless account.nil?
"#{m.split('@').first}<a href=\"#{url_for_target(account)}\" class=\"mention\">@<span>#{account.acct}</span></a>"
else
m
end
end.html_safe
end
def active_nav_class(path)
current_page?(path) ? 'active' : ''
end
......
......@@ -16,7 +16,7 @@ module AtomBuilderHelper
end
def unique_id(xml, date, id, type)
xml.id_ unique_tag(date, id, type)
xml.id_ TagManager.instance.unique_tag(date, id, type)
end
def simple_id(xml, id)
......@@ -97,32 +97,8 @@ module AtomBuilderHelper
xml['thr'].send('in-reply-to', { ref: uri, href: url, type: 'text/html' })
end
def uri_for_target(target)
if target.local?
if target.object_type == :person
account_url(target)
else
unique_tag(target.stream_entry.created_at, target.stream_entry.activity_id, target.stream_entry.activity_type)
end
else
target.uri
end
end
def url_for_target(target)
if target.local?
if target.object_type == :person
account_url(target)
else
account_stream_entry_url(target.account, target.stream_entry)
end
else
target.url
end
end
def link_mention(xml, account)
xml.link(rel: 'mentioned', href: uri_for_target(account))
xml.link(rel: 'mentioned', href: TagManager.instance.uri_for(account))
end
def link_enclosure(xml, media)
......@@ -145,7 +121,7 @@ module AtomBuilderHelper
def conditionally_formatted(activity)
if activity.is_a?(Status)
content_for_status(activity.reblog? ? activity.reblog : activity)
Formatter.instance.format(activity.reblog? ? activity.reblog : activity)
elsif activity.nil?
nil
else
......@@ -155,11 +131,11 @@ module AtomBuilderHelper
def include_author(xml, account)
object_type xml, :person
uri xml, uri_for_target(account)
uri xml, TagManager.instance.uri_for(account)
name xml, account.username
email xml, account.local? ? "#{account.acct}@#{Rails.configuration.x.local_domain}" : account.acct
summary xml, account.note
link_alternate xml, url_for_target(account)
link_alternate xml, TagManager.instance.url_for(account)
link_avatar xml, account
portable_contact xml, account
end
......@@ -176,7 +152,7 @@ module AtomBuilderHelper
# Comments need thread element
if stream_entry.threaded?
in_reply_to xml, uri_for_target(stream_entry.thread), url_for_target(stream_entry.thread)
in_reply_to xml, TagManager.instance.uri_for(stream_entry.thread), TagManager.instance.url_for(stream_entry.thread)
end
if stream_entry.targeted?
......@@ -185,9 +161,9 @@ module AtomBuilderHelper
include_author xml, stream_entry.target
else
object_type xml, stream_entry.target.object_type
simple_id xml, uri_for_target(stream_entry.target)
simple_id xml, TagManager.instance.uri_for(stream_entry.target)
title xml, stream_entry.target.title
link_alternate xml, url_for_target(stream_entry.target)
link_alternate xml, TagManager.instance.url_for(stream_entry.target)
end
# Statuses have content and author
......
require 'singleton'
class FeedManager
include Singleton
MAX_ITEMS = 800
def self.key(type, id)
def key(type, id)
"feed:#{type}:#{id}"
end
def self.filter_status?(status, follower)
def filter_status?(status, follower)
replied_to_user = status.reply? ? status.thread.account : nil
(status.reply? && !(follower.id = replied_to_user.id || follower.following?(replied_to_user)))
end
......
require 'singleton'
class Formatter
include Singleton
include ActionView::Helpers::TextHelper
include ActionView::Helpers::SanitizeHelper
def format(status)
return reformat(status) unless status.local?
html = status.text
html = encode(html)
html = link_urls(html)
html = link_mentions(html, status.mentions)
html.html_safe
end
def reformat(status)
sanitize(status.content, tags: %w(a br p), attributes: %w(href rel))
end
private
def encode(html)
HTMLEntities.new.encode(html)
end
def link_urls(html)
auto_link(html, link: :urls, html: { rel: 'nofollow noopener' })
end
def link_mentions(html, mentions)
html.gsub(Account::MENTION_RE) do |match|
acct = Account::MENTION_RE.match(match)[1]
mention = mentions.find { |mention| mention.account.acct.eql?(acct) }
return match if mention.nil?
mention_html(match, mention.account)
end
end
def mention_html(match, account)
"#{match.split('@').first}<a href=\"#{TagManager.instance.url_for(account)}\" class=\"mention\">@<span>#{account.acct}</span></a>"
end
end
require 'singleton'
class TagManager
include Singleton
include RoutingHelper
def unique_tag(date, id, type)
"tag:#{Rails.configuration.x.local_domain},#{date.strftime('%Y-%m-%d')}:objectId=#{id}:objectType=#{type}"
end
def unique_tag_to_local_id(tag, expected_type)
matches = Regexp.new("objectId=([\\d]+):objectType=#{expected_type}").match(tag)
return matches[1] unless matches.nil?
end
def local_id?(id)
id.start_with?("tag:#{Rails.configuration.x.local_domain}")
end
def uri_for(target)
return target.uri if target.respond_to?(:local?) && !target.local?
case target.object_type
when :person
account_url(target)
else
unique_tag(target.stream_entry.created_at, target.stream_entry.activity_id, target.stream_entry.activity_type)
end
end
def url_for(target)
return target.url if target.respond_to?(:local?) && !target.local?
case target.object_type
when :person
account_url(target)
else
account_stream_entry_url(target.account, target.stream_entry)
end
end
end
......@@ -21,7 +21,7 @@ class Feed
private
def key
FeedManager.key(@type, @account.id)
FeedManager.instance.key(@type, @account.id)
end
def redis
......
......@@ -15,7 +15,7 @@ class FanOutOnWriteService < BaseService
def deliver_to_followers(status)
status.account.followers.each do |follower|
next if !follower.local? || FeedManager.filter_status?(status, follower)
next if !follower.local? || FeedManager.instance.filter_status?(status, follower)
push(:home, follower, status)
end
end
......@@ -29,16 +29,16 @@ class FanOutOnWriteService < BaseService
end
def push(type, receiver, status)
redis.zadd(FeedManager.key(type, receiver.id), status.id, status.id)
redis.zadd(FeedManager.instance.key(type, receiver.id), status.id, status.id)
trim(type, receiver)
ActionCable.server.broadcast("timeline:#{receiver.id}", type: 'update', timeline: type, message: inline_render(receiver, status))
end
def trim(type, receiver)
return unless redis.zcard(FeedManager.key(type, receiver.id)) > FeedManager::MAX_ITEMS
return unless redis.zcard(FeedManager.instance.key(type, receiver.id)) > FeedManager::MAX_ITEMS
last = redis.zrevrange(FeedManager.key(type, receiver.id), FeedManager::MAX_ITEMS - 1, FeedManager::MAX_ITEMS - 1)
redis.zremrangebyscore(FeedManager.key(type, receiver.id), '-inf', "(#{last.last}")
last = redis.zrevrange(FeedManager.instance.key(type, receiver.id), FeedManager::MAX_ITEMS - 1, FeedManager::MAX_ITEMS - 1)
redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), '-inf', "(#{last.last}")
end
def redis
......
......@@ -7,8 +7,8 @@ class PrecomputeFeedService < BaseService
instant_return = []
Status.send("as_#{type}_timeline", account).order('created_at desc').limit(FeedManager::MAX_ITEMS).each do |status|
next if type == :home && FeedManager.filter_status?(status, account)
redis.zadd(FeedManager.key(type, account.id), status.id, status.id)
next if type == :home && FeedManager.instance.filter_status?(status, account)
redis.zadd(FeedManager.instance.key(type, account.id), status.id, status.id)
instant_return << status unless instant_return.size > limit
end
......
......@@ -39,10 +39,10 @@ class ProcessFeedService < BaseService
# Also record all media attachments for the status and for the reblogged status if present
unless status.new_record?
record_remote_mentions(status, entry.xpath('./xmlns:link[@rel="mentioned"]'))
process_attachments(entry, status)
process_attachments(entry.xpath('./activity:object'), status.reblog) if status.reblog?
DistributionWorker.perform_async(status.id)
end
end
......@@ -112,8 +112,8 @@ class ProcessFeedService < BaseService
def find_original_status(_xml, id)
return nil if id.nil?
if local_id?(id)
Status.find(unique_tag_to_local_id(id, 'Status'))
if TagManager.instance.local_id?(id)
Status.find(TagManager.instance.unique_tag_to_local_id(id, 'Status'))
else
Status.find_by(uri: id)
end
......
......@@ -45,7 +45,7 @@ class ProcessInteractionService < BaseService
end
def mentions_account?(xml, account)
xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]').each { |mention_link| return true if mention_link.attribute('href').value == url_for_target(account) }
xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]').each { |mention_link| return true if mention_link.attribute('href').value == TagManager.instance.url_for(account) }
false
end
......@@ -85,7 +85,7 @@ class ProcessInteractionService < BaseService
end
def status(xml)
Status.find(unique_tag_to_local_id(activity_id(xml), 'Status'))
Status.find(TagManager.instance.unique_tag_to_local_id(activity_id(xml), 'Status'))
end
def activity_id(xml)
......
......@@ -44,7 +44,7 @@ class RemoveStatusService < BaseService
end
def unpush(type, receiver, status)
redis.zremrangebyscore(FeedManager.key(type, receiver.id), status.id, status.id)
redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), status.id, status.id)
ActionCable.server.broadcast("timeline:#{receiver.id}", type: 'delete', id: status.id)
end
......
......@@ -2,7 +2,7 @@
.account-grid-card__header
.avatar= image_tag account.avatar.url(:medium)
.name
= link_to url_for_target(account) do
= link_to TagManager.instance.url_for(account) do
%span.display_name= display_name(account)
%span.username= "@#{account.acct}"
%p.note= truncate(strip_tags(account.note), length: 150)
......@@ -10,7 +10,7 @@ Nokogiri::XML::Builder.new do |xml|
include_author xml, @account
end
link_alternate xml, url_for_target(@account)
link_alternate xml, TagManager.instance.url_for(@account)
link_self xml, account_url(@account, format: 'atom')
link_hub xml, Rails.configuration.x.hub_url
link_salmon xml, api_salmon_url(@account.id)
......
......@@ -2,7 +2,7 @@ object @account
attributes :id, :username, :acct, :display_name, :note
node(:url) { |account| url_for_target(account) }
node(:url) { |account| TagManager.instance.url_for(account) }
node(:avatar) { |account| full_asset_url(account.avatar.url(:large, false)) }
node(:followers_count) { |account| account.followers.count }
node(:following_count) { |account| account.following.count }
......
object @status
attributes :id, :created_at, :in_reply_to_id
node(:uri) { |status| uri_for_target(status) }
node(:content) { |status| content_for_status(status) }
node(:url) { |status| url_for_target(status) }
node(:uri) { |status| TagManager.instance.uri_for(status) }
node(:content) { |status| Formatter.instance.format(status) }
node(:url) { |status| TagManager.instance.url_for(status) }
node(:reblogs_count) { |status| status.reblogs_count }
node(:favourites_count) { |status| status.favourites_count }
node(:favourited) { |status| current_account.favourited?(status) }
......
......@@ -2,4 +2,4 @@
<%= @account.acct %> is now following you!
<%= url_for_target(@account) %>
<%= TagManager.instance.url_for(@account) %>
......@@ -4,4 +4,4 @@ You were mentioned by <%= @status.account.acct %> in:
<%= strip_tags(@status.content) %>
<%= url_for_target(@status) %>
<%= TagManager.instance.url_for(@status) %>
......@@ -2,4 +2,4 @@
.content
%strong= link_to follow.account.acct, account_path(follow.account)
is now following
%strong= link_to follow.target_account.acct, url_for_target(follow.target_account)
%strong= link_to follow.target_account.acct, TagManager.instance.url_for(follow.target_account)
......@@ -12,7 +12,7 @@
.pre-header
%i.fa.fa-retweet
Shared by
= link_to display_name(status.account), url_for_target(status.account), class: 'name'
= link_to display_name(status.account), TagManager.instance.url_for(status.account), class: 'name'
.entry__container
.avatar
......@@ -21,10 +21,10 @@
.entry__container__container
.header
.header__left
= link_to url_for_target(proper_status(status).account), class: 'name' do
= link_to TagManager.instance.url_for(proper_status(status).account), class: 'name' do
%strong= display_name(proper_status(status).account)
= "@#{proper_status(status).account.acct}"
= link_to url_for_target(proper_status(status)), class: 'time' do
= link_to TagManager.instance.url_for(proper_status(status)), class: 'time' do
%span{ title: proper_status(status).created_at }
= relative_time(proper_status(status).created_at)
......@@ -36,7 +36,7 @@
%i.fa.fa-star
%span.counter-number= proper_status(status).favourites_count
.content= content_for_status(proper_status(status))
.content= Formatter.instance.format(proper_status(status))
%ul.media-attachments
- status.media_attachments.each do |media|
......
Nokogiri::XML::Builder.new do |xml|
xml.XRD(xmlns: 'http://docs.oasis-open.org/ns/xri/xrd-1.0') do
xml.Subject @canonical_account_uri
xml.Alias url_for_target(@account)
xml.Link(rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: url_for_target(@account))
xml.Alias TagManager.instance.url_for(@account)
xml.Link(rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: TagManager.instance.url_for(@account))
xml.Link(rel: 'http://schemas.google.com/g/2010#updates-from', type: 'application/atom+xml', href: account_url(@account, format: 'atom'))
xml.Link(rel: 'salmon', href: api_salmon_url(@account.id))
xml.Link(rel: 'magic-public-key', href: @magic_key)
......
require 'rails_helper'
RSpec.describe Oauth::ApplicationsController, type: :controller do
before do
sign_in Fabricate(:user), scope: :user
end
describe 'GET #index' do
it 'returns http success'
it 'returns http success' do
get :index
expect(response).to have_http_status(:success)
end
end
describe 'POST #create' do
......
require 'rails_helper'
RSpec.describe ApplicationHelper, type: :helper do
let(:local_domain) { Rails.configuration.x.local_domain }
describe '#unique_tag' do
it 'returns a string' do
expect(helper.unique_tag(Time.now, 12, 'Status')).to be_a String
end
end
describe '#unique_tag_to_local_id' do
it 'returns the ID part' do
expect(helper.unique_tag_to_local_id("tag:#{local_domain};objectId=12:objectType=Status", 'Status')).to eql '12'
end
end
describe '#local_id?' do
it 'returns true for a local ID' do
expect(helper.local_id?("tag:#{local_domain};objectId=12:objectType=Status")).to be true
end
it 'returns false for a foreign ID' do
expect(helper.local_id?('tag:foreign.tld;objectId=12:objectType=Status')).to be false
end
end
describe '#linkify' do
let(:alice) { Fabricate(:account, username: 'alice') }
let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', url: 'http://example.com/bob') }
it 'turns mention of remote user into link' do
status = Fabricate(:status, text: 'Hello @bob@example.com', account: bob)
status.mentions.create(account: bob)
expect(helper.linkify(status)).to match('<a href="http://example.com/bob" class="mention">@<span>bob@example.com</span></a>')
end
it 'turns mention of local user into link' do
status = Fabricate(:status, text: 'Hello @alice', account: bob)
status.mentions.create(account: alice)
expect(helper.linkify(status)).to match('<a href="http://test.host/users/alice" class="mention">@<span>alice</span></a>')
end
it 'leaves mention of unresolvable user alone' do
status = Fabricate(:status, text: 'Hello @foo', account: bob)
expect(helper.linkify(status)).to match('Hello @foo')
end
end
describe '#account_from_mentions' do
let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') }
let(:status) { Fabricate(:status, text: 'Hello @bob@example.com', account: bob) }
let(:mentions) { [Mention.create(status: status, account: bob)] }
it 'returns account' do
expect(helper.account_from_mentions('bob@example.com', mentions)).to eq bob
end
end
end
......@@ -20,7 +20,7 @@ RSpec.describe AtomBuilderHelper, type: :helper do
describe '#unique_id' do
it 'creates an id' do
time = Time.now
expect(used_in_builder { |xml| helper.unique_id(xml, time, 1, 'Status') }).to match "<id>#{helper.unique_tag(time, 1, 'Status')}</id>"
expect(used_in_builder { |xml| helper.unique_id(xml, time, 1, 'Status') }).to match "<id>#{TagManager.instance.unique_tag(time, 1, 'Status')}</id>"
end
end
......@@ -146,18 +146,10 @@ RSpec.describe AtomBuilderHelper, type: :helper do
let(:account) { Fabricate(:account, username: 'alice') }
it 'creates a link' do
expect(used_in_builder { |xml| helper.link_mention(xml, account) }).to match '<link rel="mentioned" href="http://test.host/users/alice"/>'
expect(used_in_builder { |xml| helper.link_mention(xml, account) }).to match '<link rel="mentioned" href="https://cb6e6126.ngrok.io/users/alice"/>'
end
end
describe '#disambiguate_uri' do
pending
end
describe '#disambiguate_url' do
pending
end
describe '#include_author' do
pending
end
......
require 'rails_helper'
RSpec.describe FeedManager do
describe '#key' do
subject { FeedManager.instance.key(:home, 1) }
it 'returns a string' do
expect(subject).to be_a String
end
end
describe '#filter_status?' do
let(:followee) { Fabricate(:account, username: 'alice') }
let(:status) { Fabricate(:status, text: 'Hello world', account: followee) }
let(:follower) { Fabricate(:account, username: 'bob') }
subject { FeedManager.instance.filter_status?(status, follower) }
it 'returns a boolean value' do
expect(subject).to be false
end
end
end
require 'rails_helper'
RSpec.describe Formatter do
let(:account) { Fabricate(:account, username: 'alice') }
let(:local_status) { Fabricate(:status, text: 'Hello world http://google.com', account: account) }
let(:remote_status) { Fabricate(:status, text: '<script>alert("Hello")</script> Beep boop', uri: 'beepboop', account: account) }
describe '#format' do
subject { Formatter.instance.format(local_status) }
it 'returns a string' do
expect(subject).to be_a String
end
it 'contains plain text' do
expect(subject).to match('Hello world')
end
it 'contains a link' do
expect(subject).to match('<a rel="nofollow noopener" href="http://google.com">http://google.com</a>')
end
end
describe '#reformat' do
subject { Formatter.instance.format(remote_status) }
it 'returns a string' do
expect(subject).to be_a String
end
it 'contains plain text' do
expect(subject).to match('Beep boop')
end
it 'does not contain scripts' do
expect(subject).to_not match('<script>alert("Hello")</script>')
end
end
end
require 'rails_helper'
RSpec.describe TagManager do
let(:local_domain) { Rails.configuration.x.local_domain }
describe '#unique_tag' do
it 'returns a string' do
expect(TagManager.instance.unique_tag(Time.now, 12, 'Status')).to be_a String
end
end
describe '#unique_tag_to_local_id' do
it 'returns the ID part' do
expect(TagManager.instance.unique_tag_to_local_id("tag:#{local_domain};objectId=12:objectType=Status", 'Status')).to eql '12'
end
end
describe '#local_id?' do
it 'returns true for a local ID' do
expect(TagManager.instance.local_id?("tag:#{local_domain};objectId=12:objectType=Status")).to be true
end
it 'returns false for a foreign ID' do
expect(TagManager.instance.local_id?('tag:foreign.tld;objectId=12:objectType=Status')).to be false
end
end
describe '#uri_for' do
let(:alice) { Fabricate(:account, username: 'alice') }
let(:bob) { Fabricate(:account, username: 'bob') }
let(:status) { Fabricate(:status, text: 'Hello world', account: alice) }
subject { TagManager.instance.uri_for(target) }
context 'Account' do
let(:target) { alice }
it 'returns a string' do
expect(subject).to be_a String
end
end
context 'Status' do
let(:target) { status }
it 'returns a string' do
expect(subject).to be_a String
end
end
context 'Follow' do
let(:target) { Fabricate(:follow, account: alice, target_account: bob) }
it 'returns a string' do
expect(subject).to be_a String
end
end
context 'Favourite' do
let(:target) { Fabricate(:favourite, account: bob, status: status) }
it 'returns a string' do
expect(subject).to be_a String
end
end
end
describe '#url_for' do