main
rb 204 lines 7.44 KB
Raw
1 # frozen_string_literal: true
2
3 # The "Import from GitHub" experience: connect GitHub and pick repos, or paste a
4 # public git URL. Imports run off the request thread in RepositoryImportJob; this
5 # controller only validates, enforces limits, records RepositoryImport rows, and
6 # enqueues the work.
7 class ImportsController < ApplicationController
8 before_action :require_sign_in!
9 before_action :noindex!
10
11 # Guardrails against abuse / one user saturating the workers.
12 MAX_ACTIVE_IMPORTS = Integer(ENV.fetch("IMPORT_MAX_ACTIVE_PER_USER", 10))
13 MAX_USER_VOLUME_KB = Integer(ENV.fetch("IMPORT_MAX_USER_VOLUME_KB", 10_000_000)) # ~10 GB in flight
14 MAX_REPO_SIZE_KB = RepositoryImportService::DEFAULT_MAX_SIZE_KB
15
16 # GET /import
17 def new
18 @connection = current_user.github_connection
19 @recent_imports = current_user.repository_imports.recent.limit(20)
20 @github_repos = load_github_repos(@connection) if @connection
21 end
22
23 # GET /import (list-only, e.g. progress polling of all imports)
24 def index
25 @imports = current_user.repository_imports.recent.limit(50)
26 end
27
28 # GET /import/:id — progress for a single import
29 def show
30 @import = current_user.repository_imports.find(params[:id])
31 rescue ActiveRecord::RecordNotFound
32 redirect_to new_import_path, alert: "Import not found."
33 end
34
35 # POST /import
36 # Either:
37 # source=github, repos[]=owner/name..., mode=migrate|mirror
38 # source=url, url=..., name=..., mode=..., visibility=public|private
39 def create
40 mode = params[:mode].to_s == "mirror" ? "mirror" : "migrate"
41
42 specs =
43 if params[:source].to_s == "url"
44 build_url_spec(mode)
45 else
46 build_github_specs(mode)
47 end
48
49 return if performed? # a builder already redirected with an error
50 if specs.blank?
51 return redirect_to new_import_path, alert: "Select at least one repository to import."
52 end
53
54 enqueue_imports(specs)
55 end
56
57 # POST /import/:id/retry
58 def retry
59 import = current_user.repository_imports.find(params[:id])
60 unless import.retryable?
61 return redirect_to import_path(import), alert: "This import can't be retried."
62 end
63
64 import.update!(status: "queued", error_message: nil, started_at: nil, finished_at: nil)
65 RepositoryImportJob.perform_later(import.id)
66 redirect_to import_path(import), notice: "Retrying import."
67 rescue ActiveRecord::RecordNotFound
68 redirect_to new_import_path, alert: "Import not found."
69 end
70
71 private
72
73 # Builds a single import spec from the by-URL form. Validates the URL (SSRF
74 # guard) and the target name. Redirects with an error on invalid input.
75 def build_url_spec(mode)
76 url = ImportUrlValidator.validate!(params[:url])
77 host = URI.parse(url).host
78 name = params[:name].presence || derive_name_from_url(url)
79
80 [ { source_url: url, source_host: host, mode: mode,
81 target_name: name, target_private: false, size_kb: nil,
82 description: nil, default_branch: nil } ]
83 rescue ImportUrlValidator::InvalidUrl => e
84 redirect_to new_import_path, alert: e.message
85 nil
86 end
87
88 # Builds import specs from selected GitHub repos. Reads fresh metadata from
89 # GitHub so we capture the real visibility/size/default branch (never trusting
90 # client-supplied values for security-relevant fields like private).
91 def build_github_specs(mode)
92 selected = Array(params[:repos]).reject(&:blank?).uniq
93 return [] if selected.empty?
94
95 connection = current_user.github_connection
96 return redirect_to(new_import_path, alert: "Connect GitHub first.") && nil if connection.nil?
97
98 client = connection.api_client
99 selected.filter_map do |full_name|
100 meta = client.repository(full_name)
101 next if meta.nil?
102
103 {
104 source_url: meta[:clone_url],
105 source_host: "github.com",
106 mode: mode,
107 target_name: meta[:name],
108 target_private: !!meta[:private],
109 size_kb: meta[:size_kb],
110 description: meta[:description],
111 default_branch: meta[:default_branch]
112 }
113 end
114 rescue GithubApiClient::RateLimited
115 redirect_to new_import_path, alert: "GitHub rate limit reached. Please try again shortly."
116 nil
117 rescue GithubApiClient::Error
118 redirect_to new_import_path, alert: "Couldn't read repositories from GitHub. Please try again."
119 nil
120 end
121
122 # Applies caps, resolves name collisions, creates the RepositoryImport rows,
123 # and enqueues one job per import. Redirects with a summary.
124 def enqueue_imports(specs)
125 active = current_user.repository_imports.active.count
126 volume = current_user.active_import_size_kb
127
128 created = []
129 rejected = []
130
131 specs.each do |spec|
132 if spec[:size_kb] && spec[:size_kb] > MAX_REPO_SIZE_KB
133 rejected << "#{spec[:target_name]} (too large: #{(spec[:size_kb] / 1024.0).round} MB)"
134 next
135 end
136 if active + created.length >= MAX_ACTIVE_IMPORTS
137 rejected << "#{spec[:target_name]} (import queue full — try again later)"
138 next
139 end
140 if volume + (spec[:size_kb] || 0) > MAX_USER_VOLUME_KB
141 rejected << "#{spec[:target_name]} (would exceed your in-flight import limit)"
142 next
143 end
144
145 import = current_user.repository_imports.create!(
146 source_url: spec[:source_url],
147 source_host: spec[:source_host],
148 mode: spec[:mode],
149 target_name: unique_repo_name(spec[:target_name]),
150 target_private: spec[:target_private],
151 description: spec[:description],
152 default_branch: spec[:default_branch],
153 source_size_kb: spec[:size_kb],
154 status: "queued"
155 )
156 RepositoryImportJob.perform_later(import.id)
157 created << import
158 volume += (spec[:size_kb] || 0)
159 end
160
161 notice = created.any? ? "Queued #{created.length} import#{'s' if created.length != 1}." : nil
162 alert = rejected.any? ? "Skipped: #{rejected.join('; ')}." : nil
163
164 if created.length == 1 && rejected.empty?
165 redirect_to import_path(created.first), notice: notice
166 else
167 redirect_to new_import_path, notice: notice, alert: alert
168 end
169 end
170
171 # Ensures the target name doesn't collide with an existing repo (or another
172 # queued import) by appending -1, -2, … This makes bulk imports and retries
173 # idempotent rather than failing on the unique (user, name) index.
174 def unique_repo_name(base)
175 base = base.to_s.gsub(/[^a-zA-Z0-9._-]/, "-").gsub(/-{2,}/, "-").gsub(/\A[-.]+|[-.]+\z/, "")
176 base = "imported-repo" if base.blank?
177
178 taken = current_user.repositories.pluck(:name).map(&:downcase).to_set
179 taken.merge(current_user.repository_imports.active.pluck(:target_name).map(&:downcase))
180
181 return base unless taken.include?(base.downcase)
182
183 (1..1000).each do |n|
184 candidate = "#{base}-#{n}"
185 return candidate unless taken.include?(candidate.downcase)
186 end
187 "#{base}-#{SecureRandom.hex(3)}"
188 end
189
190 def derive_name_from_url(url)
191 File.basename(URI.parse(url).path.to_s).sub(/\.git\z/, "").presence || "imported-repo"
192 end
193
194 # Lists the user's GitHub repos for the picker. Cached briefly so re-rendering
195 # the page (and Turbo revisits) don't re-hit GitHub. Never blocks the page on a
196 # rate-limit/outage — returns nil and the view shows a fallback.
197 def load_github_repos(connection)
198 Rails.cache.fetch("github_repos/#{connection.id}/#{connection.updated_at.to_i}", expires_in: 5.minutes) do
199 connection.api_client.list_repositories
200 end
201 rescue GithubApiClient::RateLimited, GithubApiClient::Error, GithubApiClient::Unauthorized
202 nil
203 end
204 end