diff --git a/Gemfile b/Gemfile index 6e218f2..b6dc066 100644 --- a/Gemfile +++ b/Gemfile @@ -40,6 +40,11 @@ gem 'puma', '~> 7.2.1' gem 'sass-rails', '~> 5.0' # Use Uglifier as compressor for JavaScript assets gem 'uglifier', '>= 1.3.0' + +# used by lib/link_preview.rb +gem 'metainspector', '~> 5.15' +gem 'microformats', '~> 4.5' + # See https://github.com/rails/execjs#readme for more supported runtimes # gem 'mini_racer', platforms: :ruby @@ -52,8 +57,6 @@ gem 'jbuilder', '~> 2.5' # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', '>= 1.1.0', require: false -#gem 'aws-sdk-rails', '~> 2.1.0' #email with AWS SES - group :production do gem "aws-sdk-s3", require: false end diff --git a/Gemfile.lock b/Gemfile.lock index 26585a0..a8a8ffd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -135,6 +135,7 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) + domain_name (0.6.20240107) dotenv (2.7.6) dotenv-rails (2.7.6) dotenv (= 2.7.6) @@ -143,12 +144,35 @@ GEM erb (6.0.1.1) erubi (1.13.1) execjs (2.10.0) + faraday (2.14.3) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-encoding (0.0.6) + faraday + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) + faraday-gzip (3.1.0) + faraday (>= 2.0, < 3) + zlib (~> 3.0) + faraday-http-cache (2.7.0) + faraday (>= 0.8) + faraday-net_http (3.4.4) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) + fastimage (2.4.1) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-linux-gnu) globalid (1.2.1) activesupport (>= 6.1) + http-cookie (1.1.6) + domain_name (~> 0.5) i18n (1.14.7) concurrent-ruby (~> 1.0) image_processing (1.13.0) @@ -163,6 +187,7 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) jmespath (1.6.2) + json (2.20.0) kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -193,11 +218,29 @@ GEM net-smtp marcel (1.0.4) matrix (0.4.3) + metainspector (5.17.2) + addressable (~> 2.9.0) + faraday (~> 2.5) + faraday-cookie_jar (~> 0.0) + faraday-encoding (~> 0.0) + faraday-follow_redirects (~> 0.3) + faraday-gzip (>= 0.1, < 4.0) + faraday-http-cache (~> 2.5) + faraday-retry (~> 2.0) + fastimage (~> 2.2) + nesty (~> 1.0) + nokogiri (~> 1.19.0) + microformats (4.5.0) + json + nokogiri mini_magick (4.13.2) mini_mime (1.1.5) minitest (5.25.5) msgpack (1.8.0) mutex_m (0.3.0) + nesty (1.0.2) + net-http (0.9.1) + uri (>= 0.11.1) net-imap (0.5.15) date net-protocol @@ -325,6 +368,7 @@ GEM concurrent-ruby (~> 1.0) uglifier (4.2.1) execjs (>= 0.3.0, < 3) + uri (1.1.1) warden (1.2.9) rack (>= 2.0.9) web-console (4.2.1) @@ -341,6 +385,7 @@ GEM xpath (3.2.0) nokogiri (~> 1.8) zeitwerk (2.7.3) + zlib (3.2.3) PLATFORMS aarch64-linux @@ -364,6 +409,8 @@ DEPENDENCIES kaminari (~> 1.2.1) letter_opener (~> 1.7.0) listen (>= 3.0.5, < 3.2) + metainspector (~> 5.15) + microformats (~> 4.5) pg (~> 1.5.9) puma (~> 7.2.1) rails (~> 7.1.5.2) diff --git a/app/assets/stylesheets/misc.css b/app/assets/stylesheets/misc.css index 9b4cecb..673ca00 100644 --- a/app/assets/stylesheets/misc.css +++ b/app/assets/stylesheets/misc.css @@ -56,3 +56,32 @@ details.recent_activity summary h2:before { span.reaction_age { font-size: small; } + +.link-preview-card { + display: block; + border: 1px solid #ddd; + border-radius: 4px; + overflow: hidden; + text-decoration: none; + color: inherit; + max-width: 70%; + margin: 1rem 0; +} +.preview-content { + padding: 1rem; +} +.preview-title { + display: block; + font-size: 1.2rem; + margin-bottom: 0.5rem; +} +.preview-description { + font-size: 0.8rem; + color: #555; + margin: 0; + line-height: 1.4; +} +.preview-image { + max-width: 100%; + height: auto; +} diff --git a/app/controllers/micropub_controller.rb b/app/controllers/micropub_controller.rb index 168cf35..d040322 100644 --- a/app/controllers/micropub_controller.rb +++ b/app/controllers/micropub_controller.rb @@ -16,6 +16,10 @@ def query "name": "Post", "properties": [ "content", "published" ] }, + { + "type": "photo", + "name": "Image" + } ] }, status: 200 elsif params[:q] == "source" @@ -158,24 +162,58 @@ def post_from_url(params_url) # supported params: name, content, published def post_from_params(params) - unless params[:h] and params[:h]=="entry" + if params[:h] and params[:h]=="entry" + # we handle this case + elsif params[:type] and params[:type].first=="h-entry" + raise JsonError.new("invalid_request","JSON params not supported by this server",400) + else raise JsonError.new("invalid_request","Only h-entry types supported by this server",400) end + content = "" - if params[:content] + if params["bookmark-of"] + content_array = [] + if params[:name] + content_array << "## Bookmark: #{params[:name]}" + else + content_array << "## Bookmark" + end + content_array << params[:content] if params[:content] + link_preview = LinkPreview.fetch(params["bookmark-of"]) + content_array << LinkPreview.render(params["bookmark-of"],link_preview) + content = content_array.join("\n\n") + elsif params[:content] content = params[:content] if params[:name] content = "# #{params[:name]}\n\n#{content}" end elsif params[:name] - content = "# #{params[:name]}" + content = "## #{params[:name]}" + elsif params["like-of"] + link_preview = LinkPreview.fetch(params["like-of"]) + content = "
❤️ Liked #{link_preview[:title]}
" + content += LinkPreview.render(params['like-of'],link_preview) + elsif params["repost-of"] + link_preview = LinkPreview.fetch(params["repost-of"]) + content = "🔁 Reposted: #{link_preview[:title]}
" + content += LinkPreview.render(params['repost-of'],link_preview) else - raise JsonError.new("invalid_request","New h-entry must have content or a name (or both)",400) + raise JsonError.new("invalid_request","New h-entry must have content or a name (or both), or have a repost-of or like-of",400) end datetime = DateTime.now if params[:published] datetime = DateTime.parse(params[:published]) end + + ## these blocks insert in-reply-to or bookmark-of (etc..) links directly in the content + ## we might consider richer database structure to store/display that data better. + if params["in-reply-to"] + link_preview = LinkPreview.fetch(params["in-reply-to"]) + reply_html = "In reply to: #{link_preview[:title]}" + content += "\n\n#{reply_html}" + content += "\n\n#{LinkPreview.render(params["in-reply-to"], link_preview)}" + end + post = Post.new post.datetime = datetime post.content = content diff --git a/lib/indie_auth_client.rb b/lib/indie_auth_client.rb index 5d29f93..e06281f 100644 --- a/lib/indie_auth_client.rb +++ b/lib/indie_auth_client.rb @@ -15,8 +15,9 @@ def to_html def valid_redirect? (redirect) return true if redirect.start_with? @client_id + return true if URI.parse(redirect).origin == URI.parse(@client_id).origin return true if @redirects.include? redirect - #puts "redirect validation error. Indie auth client #{@client_id} requesting redirect to #{redirect} which does not have #{@client_id} as a prefix, and is not in list of valid redirects fetched: #{@redirects.join(",")}" + # puts "redirect validation error. Indie auth client #{@client_id} requesting redirect to #{redirect} which does not have #{@client_id} as a prefix, and is not in list of valid redirects fetched: [#{@redirects.join(",")}]" return false end diff --git a/lib/link_preview.rb b/lib/link_preview.rb new file mode 100644 index 0000000..c142d2c --- /dev/null +++ b/lib/link_preview.rb @@ -0,0 +1,87 @@ + +module LinkPreview + require 'open-uri' + require 'nokogiri' + require 'microformats' + require 'metainspector' + require 'cgi' + + def self.fetch(url) + begin + uri = URI.parse(url) + unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + raise ArgumentError, "Only HTTP/HTTPS URLs are allowed" + end + html = uri.open.read + rescue StandardError => e + puts "Failed to fetch URL: #{e.message}" + return nil + end + + doc = Nokogiri::HTML(html) + + # Check for standard mf2 root classes + if false # mf2 parsing isn't working well TODO: investigate further +# if doc.at_css('.h-entry, .h-card, .h-event') + # puts "mf2 detected! Parsing with microformats-ruby..." + parsed = Microformats.parse(html) + entry = parsed.items.first + raw_description = entry.properties['summary']&.first || entry.properties['content']&.first + clean_description = if raw_description.is_a?(Hash) + # Handle both string and symbol keys just in case + raw_description['value'] || raw_description[:value] || "" + else + raw_description + end + return { + title: entry.properties['name']&.first, + description: clean_description.truncate(250, separator: ' '), + image: entry.properties['photo']&.first, + type: 'mf2' + } + + else + # puts "No mf2 found. Falling back to MetaInspector..." + page = MetaInspector.new(url, document: html) + + return { + title: page.best_title, + description: page.best_description, + image: page.images.best, + type: 'opengraph' + } + end + end + + + def self.render(url, preview) + # Fallback to a standard link if the preview hash is nil + return %Q(#{url}) if preview.nil? + + # Safely escape text to prevent XSS injection + title = CGI.escapeHTML(preview[:title] || url) + description = CGI.escapeHTML(preview[:description] || "") + + # Conditionally render the image tag only if an image exists + image_html = if preview[:image] + %Q(#{description}
) + + + <<~HTML + + #{image_html} +