0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 07:23:30 +08:00
discourse/app/controllers/metadata_controller.rb
Rafael dos Santos Silva 6167f3bbac
FEATURE: Accept shared files via the Web Share Target (#41030)
Previously, Discourse only registered as a Level 1 Web Share Target — a
`GET` text-only handler that could not receive images or other files
from the OS share sheet.

This change registers a modern `POST`/`multipart/form-data` share
target. The service worker intercepts the incoming share, stashes the
shared title, text, url, and files in the Cache API, and redirects to a
`share-target` route that opens a modal asking whether to start a new
topic, a new message, or save the content to add to the next reply.
Topics and messages open the composer pre-filled with the shared text
and uploaded files; the reply option buffers the content and injects it
into the next reply composer that opens.

### Notes
- The file `accept` list covers images, video, audio, PDFs, and common
document types; the server still enforces `authorized_extensions`.
- Adds a `composer:uploader-ready` app event so shared files are only
handed to the uploader once it is bound, avoiding a race on cold
composer open.
- Only Android Chromium-based browsers currently implement file-capable
share targets; iOS has no Web Share Target support.

---------

Co-authored-by: Penar Musaraj <pmusaraj@gmail.com>
2026-06-18 17:35:09 -03:00

156 lines
4.8 KiB
Ruby
Vendored

# frozen_string_literal: true
class MetadataController < ApplicationController
layout false
skip_before_action :preload_json,
:check_xhr,
:redirect_to_login_if_required,
:redirect_to_profile_if_required
def manifest
expires_in 1.minute
render json: default_manifest.to_json, content_type: "application/manifest+json"
end
def opensearch
expires_in 1.minute
render template: "metadata/opensearch", formats: [:xml]
end
def app_association_android
raise Discourse::NotFound if SiteSetting.app_association_android.blank?
expires_in 1.minute
render plain: SiteSetting.app_association_android, content_type: "application/json"
end
def app_association_ios
raise Discourse::NotFound if SiteSetting.app_association_ios.blank?
expires_in 1.minute
render plain: SiteSetting.app_association_ios, content_type: "application/json"
end
def discourse_id_challenge
token = Discourse.redis.get("discourse_id_challenge_token")
raise Discourse::NotFound if token.blank?
domain = Discourse.current_hostname
path = Discourse.base_path.presence
expires_in 5.minutes
render json: { token:, domain:, path: }.compact
end
private
# The accepted file types for the Web Share Target, derived from the
# `authorized_extensions` site setting so the share sheet only offers
# Discourse for files it can actually accept. Each extension is expressed in
# the dotted form the Web Share Target spec expects (e.g. ".jpg"). Falls back
# to any file type when the site authorizes all extensions ("*").
def share_target_accept
extensions =
SiteSetting.authorized_extensions.gsub(/[\s.]+/, "").downcase.split("|").reject(&:blank?)
return ["*/*"] if extensions.include?("*")
extensions.map { |extension| ".#{extension}" }.presence || ["*/*"]
end
def default_manifest
display = "standalone"
if request.user_agent
regex = Regexp.new(SiteSetting.pwa_display_browser_regex)
display = "browser" if regex.match(request.user_agent)
end
scheme_id = view_context.scheme_id
manifest = {
name: SiteSetting.title,
short_name:
SiteSetting.short_title.presence ||
SiteSetting.title.truncate(12, separator: " ", omission: ""),
description: SiteSetting.site_description,
display: display,
start_url: Discourse.base_path.present? ? "#{Discourse.base_path}/" : "/",
background_color: "##{ColorScheme.hex_for_name("secondary", scheme_id)}",
theme_color: "##{ColorScheme.hex_for_name("header_background", scheme_id)}",
icons: [],
share_target: {
action: "#{Discourse.base_path}/share-target",
method: "POST",
enctype: "multipart/form-data",
params: {
title: "title",
text: "text",
url: "url",
files: [{ name: "files", accept: share_target_accept }],
},
},
shortcuts: [
{
name: I18n.t("js.topic.create_long"),
short_name: I18n.t("js.topic.create"),
url: "#{Discourse.base_path}/new-topic",
},
{
name: I18n.t("js.user.messages.inbox"),
short_name: I18n.t("js.user.messages.inbox"),
url: "#{Discourse.base_path}/my/messages",
},
{
name: I18n.t("js.user.bookmarks"),
short_name: I18n.t("js.user.bookmarks"),
url: "#{Discourse.base_path}/my/activity/bookmarks",
},
{
name: I18n.t("js.filters.top.title"),
short_name: I18n.t("js.filters.top.title"),
url: "#{Discourse.base_path}/top",
},
],
}
logo = SiteSetting.site_manifest_icon_url
if logo
icon_entry = {
src: UrlHelper.absolute(logo),
sizes: "512x512",
type: MiniMime.lookup_by_filename(logo)&.content_type || "image/png",
}
manifest[:icons] << icon_entry.dup
icon_entry[:purpose] = "maskable"
manifest[:icons] << icon_entry
end
SiteSetting
.manifest_screenshots
.split("|")
.each do |image|
next unless Discourse.store.has_been_uploaded?(image)
upload = Upload.find_by(sha1: Upload.extract_sha1(image))
next if upload.nil?
manifest[:screenshots] = [] if manifest.dig(:screenshots).nil?
manifest[:screenshots] << {
src: UrlHelper.absolute(image),
sizes: "#{upload.width}x#{upload.height}",
type: "image/#{upload.extension}",
}
end
if current_user && current_user.trust_level >= 1 &&
SiteSetting.native_app_install_banner_android
manifest =
manifest.merge(
prefer_related_applications: true,
related_applications: [{ platform: "play", id: SiteSetting.android_app_id }],
)
end
manifest
end
end