Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/assets/stylesheets/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@
}

/* ═══════════════════════════════════════════════════════════════════════════
CONNECTED NOTES
Connected Documents
Card grid replacing the old backlinks lists. Each card shows a linked
document title + plain-text excerpt. Clicking a card opens a popup
with the full rich-text preview.
Expand Down
22 changes: 3 additions & 19 deletions app/jobs/storage_migration_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,7 @@ def perform(migration_id)
end

migration.reload
if migration.cancelled?
# Already marked cancelled
elsif migration.failed_items.positive?
migration.update!(status: "completed", completed_at: Time.current)
else
unless migration.cancelled?
migration.update!(status: "completed", completed_at: Time.current)
end
rescue => e
Expand Down Expand Up @@ -82,8 +78,7 @@ def migrate_backup(record, from_adapter, to_adapter, migration)
end

def build_adapter(provider)
case provider
when "local"
if provider == "local"
StorageAdapter::Local.new
else
setting = StorageSetting.active_setting
Expand All @@ -92,18 +87,7 @@ def build_adapter(provider)
oauth = OAuthManager.new
setting = oauth.ensure_fresh_token!(setting) if oauth.oauth_provider?(setting.provider)

case provider
when "s3"
StorageAdapter::S3.new(config: setting.config_data)
when "dropbox"
StorageAdapter::Dropbox.new(config: setting.config_data)
when "google_drive"
StorageAdapter::GoogleDrive.new(config: setting.config_data)
when "onedrive"
StorageAdapter::OneDrive.new(config: setting.config_data)
else
StorageAdapter::Local.new
end
StorageAdapter.build(provider, setting.config_data)
end
end

Expand Down
22 changes: 8 additions & 14 deletions app/services/oauth_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def authorize_url(provider, redirect_uri:)
config = provider_config!(provider)

params = {
client_id: client_id(config),
client_id: env_credential(config[:client_id_env]),
redirect_uri: redirect_uri,
response_type: "code"
}
Expand Down Expand Up @@ -66,8 +66,8 @@ def handle_callback(provider, code:, redirect_uri:)
grant_type: "authorization_code",
code: code,
redirect_uri: redirect_uri,
client_id: client_id(config),
client_secret: client_secret(config)
client_id: env_credential(config[:client_id_env]),
client_secret: env_credential(config[:client_secret_env])
}
)

Expand All @@ -89,8 +89,8 @@ def refresh_access_token(provider, refresh_token:)
form: {
grant_type: "refresh_token",
refresh_token: refresh_token,
client_id: client_id(config),
client_secret: client_secret(config)
client_id: env_credential(config[:client_id_env]),
client_secret: env_credential(config[:client_secret_env])
}
)

Expand Down Expand Up @@ -152,16 +152,10 @@ def provider_config!(provider)
PROVIDERS[key]
end

def client_id(config)
ENV.fetch(config[:client_id_env])
def env_credential(env_key)
ENV.fetch(env_key)
rescue KeyError
raise ConfigurationError, "Missing ENV variable #{config[:client_id_env]}. Set it in your .env file or Docker environment."
end

def client_secret(config)
ENV.fetch(config[:client_secret_env])
rescue KeyError
raise ConfigurationError, "Missing ENV variable #{config[:client_secret_env]}. Set it in your .env file or Docker environment."
raise ConfigurationError, "Missing ENV variable #{env_key}. Set it in your .env file or Docker environment."
end

def http_client
Expand Down
40 changes: 27 additions & 13 deletions app/services/storage_adapter/base.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
module StorageAdapter
ADAPTER_CLASSES = {
"s3" => "S3",
"dropbox" => "Dropbox",
"google_drive" => "GoogleDrive",
"onedrive" => "OneDrive"
}.freeze

def self.resolve
setting = StorageSetting.active_setting rescue nil

Expand All @@ -13,22 +20,19 @@ def self.resolve
# Refresh OAuth tokens if expired
oauth = OAuthManager.new
setting = oauth.ensure_fresh_token!(setting) if oauth.oauth_provider?(setting.provider)

case setting.provider
when "s3"
S3.new(config: setting.config_data)
when "dropbox"
Dropbox.new(config: setting.config_data)
when "google_drive"
GoogleDrive.new(config: setting.config_data)
when "onedrive"
OneDrive.new(config: setting.config_data)
else
Local.new
end
build(setting.provider, setting.config_data)
end
end

def self.build(provider, config_data = {})
klass_name = ADAPTER_CLASSES[provider]
return Local.new unless klass_name

const_get(klass_name).new(config: config_data)
end

class ApiError < StandardError; end

class Base
def upload(file_path, key, namespace: :files)
raise NotImplementedError, "#{self.class}#upload must be implemented"
Expand All @@ -46,12 +50,22 @@ def list(namespace: :files)
raise NotImplementedError, "#{self.class}#list must be implemented"
end

def exist?(key, namespace: :files)
list(namespace: namespace).include?(key)
end

def url(key, namespace: :files, expires_in: 1.hour)
raise NotImplementedError, "#{self.class}#url must be implemented"
end

def test_connection
raise NotImplementedError, "#{self.class}#test_connection must be implemented"
end

private

def auth_client(timeout: 30)
HTTP.timeout(timeout).auth("Bearer #{@access_token}")
end
end
end
27 changes: 18 additions & 9 deletions app/services/storage_adapter/dropbox.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ class Dropbox < Base
ROOT_FOLDER = "/Apps/Inbox".freeze

def initialize(config: {})
@access_token = config["access_token"] || config[:access_token]
config = config.with_indifferent_access
@access_token = config[:access_token]
end

def upload(file_path, key, namespace: :files)
Expand Down Expand Up @@ -59,6 +60,15 @@ def list(namespace: :files)
raise
end

def exist?(key, namespace: :files)
path = dropbox_path(key, namespace)
api_request("/files/get_metadata", path: path)
true
rescue ApiError => e
return false if e.message.include?("path/not_found")
raise
end

def url(key, namespace: :files, expires_in: 1.hour)
path = dropbox_path(key, namespace)
body = api_request("/files/get_temporary_link", path: path)
Expand All @@ -85,24 +95,25 @@ def test_connection
{ ok: false, error: e.message }
end

class ApiError < StandardError; end

private

def dropbox_path(key, namespace)
"#{ROOT_FOLDER}/#{namespace}/#{key}"
end

def ensure_folder_exists!(namespace)
return if @created_folders&.include?(namespace)

path = "#{ROOT_FOLDER}/#{namespace}"
api_request("/files/create_folder_v2", path: path, autorename: false)
rescue ApiError => e
raise unless e.message.include?("path/conflict")
ensure
(@created_folders ||= Set.new) << namespace
end

def api_request(endpoint, **params)
response = HTTP.timeout(30)
.auth("Bearer #{@access_token}")
response = auth_client
.headers("Content-Type" => "application/json")
.post("#{BASE_URL}#{endpoint}", body: params.to_json)

Expand All @@ -117,8 +128,7 @@ def api_request(endpoint, **params)
end

def content_request(endpoint, body:, api_arg:)
response = HTTP.timeout(60)
.auth("Bearer #{@access_token}")
response = auth_client(timeout: 60)
.headers(
"Content-Type" => "application/octet-stream",
"Dropbox-API-Arg" => api_arg.to_json
Expand All @@ -135,8 +145,7 @@ def content_request(endpoint, body:, api_arg:)
end

def content_download(endpoint, api_arg:)
response = HTTP.timeout(60)
.auth("Bearer #{@access_token}")
response = auth_client(timeout: 60)
.headers("Dropbox-API-Arg" => api_arg.to_json)
.post("#{CONTENT_URL}#{endpoint}")

Expand Down
40 changes: 20 additions & 20 deletions app/services/storage_adapter/google_drive.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ class GoogleDrive < Base
FOLDER_MIME = "application/vnd.google-apps.folder".freeze

def initialize(config: {})
@access_token = config["access_token"] || config[:access_token]
@folder_ids = config["folder_ids"] || config[:folder_ids] || {}
config = config.with_indifferent_access
@access_token = config[:access_token]
@folder_ids = (config[:folder_ids] || {}).stringify_keys
end

def upload(file_path, key, namespace: :files)
Expand Down Expand Up @@ -44,8 +45,7 @@ def delete(key, namespace: :files)
file = find_file(key, folder_id)
return unless file

HTTP.timeout(30)
.auth("Bearer #{@access_token}")
auth_client
.delete("#{API_URL}/files/#{file["id"]}")
end

Expand All @@ -72,6 +72,12 @@ def list(namespace: :files)
results
end

def exist?(key, namespace: :files)
folder_id = namespace_folder_id(namespace)
return false unless folder_id
!!find_file(key, folder_id)
end

def url(key, namespace: :files, expires_in: 1.hour)
folder_id = namespace_folder_id(namespace)
file = find_file(key, folder_id)
Expand All @@ -95,8 +101,7 @@ def test_connection
raise ApiError, "Test file content mismatch" unless response.body.to_s == "ok"

# Delete it
HTTP.timeout(30)
.auth("Bearer #{@access_token}")
auth_client
.delete("#{API_URL}/files/#{file["id"]}")

{ ok: true }
Expand All @@ -109,8 +114,6 @@ def folder_ids
@folder_ids.dup
end

class ApiError < StandardError; end

private

def namespace_folder_id(namespace)
Expand All @@ -130,15 +133,15 @@ def ensure_namespace_folder!(namespace)

def ensure_folder!(name, parent_id)
# Search for existing folder
query = "name = '#{name}' and '#{parent_id}' in parents and mimeType = '#{FOLDER_MIME}' and trashed = false"
escaped_name = name.gsub("'", "\\\\'")
query = "name = '#{escaped_name}' and '#{parent_id}' in parents and mimeType = '#{FOLDER_MIME}' and trashed = false"
body = api_json("/files", params: { q: query, fields: "files(id,name)", pageSize: 1 })
files = body["files"] || []
return files.first["id"] if files.any?

# Create folder
metadata = { name: name, mimeType: FOLDER_MIME, parents: [ parent_id ] }
response = HTTP.timeout(30)
.auth("Bearer #{@access_token}")
response = auth_client
.headers("Content-Type" => "application/json")
.post("#{API_URL}/files", body: metadata.to_json)

Expand All @@ -149,7 +152,8 @@ def ensure_folder!(name, parent_id)
def find_file(name, folder_id)
return nil unless folder_id

query = "name = '#{name}' and '#{folder_id}' in parents and trashed = false"
escaped_name = name.gsub("'", "\\\\'")
query = "name = '#{escaped_name}' and '#{folder_id}' in parents and trashed = false"
body = api_json("/files", params: { q: query, fields: "files(id,name)", pageSize: 1 })
(body["files"] || []).first
end
Expand All @@ -161,8 +165,7 @@ def create_file(name, file_path, folder_id, content: nil)
boundary = "StorageAdapter#{SecureRandom.hex(8)}"
multipart = build_multipart(boundary, metadata, body)

response = HTTP.timeout(60)
.auth("Bearer #{@access_token}")
response = auth_client(timeout: 60)
.headers("Content-Type" => "multipart/related; boundary=#{boundary}")
.post("#{UPLOAD_URL}/files?uploadType=multipart", body: multipart)

Expand All @@ -172,8 +175,7 @@ def create_file(name, file_path, folder_id, content: nil)
def update_file(file_id, file_path)
body = File.binread(file_path)

response = HTTP.timeout(60)
.auth("Bearer #{@access_token}")
response = auth_client(timeout: 60)
.headers("Content-Type" => "application/octet-stream")
.patch("#{UPLOAD_URL}/files/#{file_id}?uploadType=media", body: body)

Expand All @@ -197,16 +199,14 @@ def api_json(path, params: {})
url = "#{API_URL}#{path}"
url += "?#{query_string}" unless query_string.empty?

response = HTTP.timeout(30)
.auth("Bearer #{@access_token}")
response = auth_client
.get(url)

parse_response!(response)
end

def api_get(path)
response = HTTP.timeout(30)
.auth("Bearer #{@access_token}")
response = auth_client
.get("#{API_URL}#{path}")

if response.status >= 400
Expand Down
4 changes: 4 additions & 0 deletions app/services/storage_adapter/local.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ def list(namespace: :files)
.sort
end

def exist?(key, namespace: :files)
namespace_path(namespace).join(key).exist?
end

def url(key, namespace: :files, expires_in: 1.hour)
namespace_path(namespace).join(key).to_s
end
Expand Down
Loading