gitlyx / repo / file_routes.v
320 lines · 271 sloc · 10.09 KB · 63435c29f3c806c217e90701405b820b67cd180b
Raw
1module main
2
3import veb
4import os
5import git
6
7// GET /:username/:repo_name/new/:branch_name - Show create file form
8@['/:username/:repo_name/new/:branch_name']
9pub fn (mut app App) new_file(username string, repo_name string, branch_name string) veb.Result {
10 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
11
12 if !ctx.logged_in || repo.user_id != ctx.user.id {
13 return ctx.redirect_to_repository(username, repo_name)
14 }
15
16 default_content := ''
17 default_filename := ''
18 return $veb.html('templates/new_file.html')
19}
20
21// GET /:username/:repo_name/new-ci-file - Show create .gitly-ci.yml form (pre-filled)
22@['/:username/:repo_name/new-ci-file']
23pub fn (mut app App) new_ci_file(username string, repo_name string) veb.Result {
24 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
25
26 if !ctx.logged_in || repo.user_id != ctx.user.id {
27 return ctx.redirect_to_repository(username, repo_name)
28 }
29
30 branch_name := repo.primary_branch
31 default_filename := '.gitly-ci.yml'
32 default_content := 'jobs:
33 build:
34 steps:
35 - name: Build
36 run: echo "hello world"
37 - name: Test
38 run: echo "running tests"
39'
40 return $veb.html('templates/new_file.html')
41}
42
43// GET /:username/:repo_name/edit/:branch_name/:path... - Show edit file form
44@['/:username/:repo_name/edit/:branch_name/:path...']
45pub fn (mut app App) edit_file(username string, repo_name string, branch_name string, path string) veb.Result {
46 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
47
48 if !ctx.logged_in || repo.user_id != ctx.user.id {
49 return ctx.redirect_to_repository(username, repo_name)
50 }
51
52 file_content := repo.read_file(branch_name, path)
53
54 return $veb.html('templates/edit_file.html')
55}
56
57// POST /:username/:repo_name/update-file - Save edited file
58@['/:username/:repo_name/update-file'; post]
59pub fn (mut app App) handle_update_file(username string, repo_name string) veb.Result {
60 mut repo := app.find_repo_by_name_and_username(repo_name, username) or {
61 return ctx.not_found()
62 }
63
64 if !ctx.logged_in || repo.user_id != ctx.user.id {
65 return ctx.redirect_to_repository(username, repo_name)
66 }
67
68 file_path := ctx.form['file_path']
69 file_content := ctx.form['file_content']
70 branch_name := ctx.form['branch']
71 commit_message := ctx.form['commit_message']
72 mut actual_branch := ''
73
74 if commit_message == '' {
75 ctx.error('Commit message is required')
76 path := file_path
77 return $veb.html('templates/edit_file.html')
78 }
79
80 actual_branch = branch_name
81 if actual_branch == '' {
82 actual_branch = repo.primary_branch
83 }
84
85 success := app.create_file_in_bare_repo(mut repo, actual_branch, file_path, file_content,
86 commit_message, ctx.user.username)
87
88 if !success {
89 ctx.error('Failed to save file')
90 path := file_path
91 return $veb.html('templates/edit_file.html')
92 }
93
94 // Clear cached files so the updated file shows up
95 app.delete_repository_files_in_branch(repo.id, actual_branch) or {}
96
97 app.update_repo_after_push(repo.id, actual_branch) or {
98 app.warn('Failed to update repo after file edit: ${err}')
99 }
100
101 // Trigger CI if applicable
102 if file_path == '.gitly-ci.yml' {
103 spawn app.trigger_ci_with_config(repo.id, actual_branch, file_content)
104 } else {
105 spawn app.trigger_ci_if_configured(repo.id, actual_branch)
106 }
107
108 return ctx.redirect('/${username}/${repo_name}/blob/${actual_branch}/${file_path}')
109}
110
111// POST /:username/:repo_name/create-file - Create a file in the repo
112@['/:username/:repo_name/create-file'; post]
113pub fn (mut app App) handle_create_file(username string, repo_name string) veb.Result {
114 mut repo := app.find_repo_by_name_and_username(repo_name, username) or {
115 return ctx.not_found()
116 }
117
118 if !ctx.logged_in || repo.user_id != ctx.user.id {
119 return ctx.redirect_to_repository(username, repo_name)
120 }
121
122 file_path := ctx.form['file_path']
123 file_content := ctx.form['file_content']
124 branch_name := ctx.form['branch']
125 commit_message := ctx.form['commit_message']
126 mut actual_branch := ''
127
128 if file_path == '' {
129 ctx.error('File path is required')
130 default_content := file_content
131 default_filename := file_path
132 return $veb.html('templates/new_file.html')
133 }
134
135 if commit_message == '' {
136 ctx.error('Commit message is required')
137 default_content := file_content
138 default_filename := file_path
139 return $veb.html('templates/new_file.html')
140 }
141
142 // Sanitize file path
143 if file_path.contains('..') || file_path.contains('&') || file_path.contains(';') {
144 ctx.error('Invalid file path')
145 default_content := file_content
146 default_filename := file_path
147 return $veb.html('templates/new_file.html')
148 }
149
150 actual_branch = branch_name
151 if actual_branch == '' {
152 actual_branch = repo.primary_branch
153 }
154
155 success := app.create_file_in_bare_repo(mut repo, actual_branch, file_path, file_content,
156 commit_message, ctx.user.username)
157
158 if !success {
159 ctx.error('Failed to create file')
160 default_content := file_content
161 default_filename := file_path
162 return $veb.html('templates/new_file.html')
163 }
164
165 // Clear cached files so the new file shows up
166 app.delete_repository_files_in_branch(repo.id, actual_branch) or {}
167
168 // Update repo data
169 app.update_repo_after_push(repo.id, actual_branch) or {
170 app.warn('Failed to update repo after file creation: ${err}')
171 }
172
173 // Trigger CI — if we just created .gitly-ci.yml, pass the content directly
174 if file_path == '.gitly-ci.yml' {
175 spawn app.trigger_ci_with_config(repo.id, actual_branch, file_content)
176 } else {
177 spawn app.trigger_ci_if_configured(repo.id, actual_branch)
178 }
179
180 return ctx.redirect('/${username}/${repo_name}')
181}
182
183// Creates a file in a bare git repo using plumbing commands
184fn (mut app App) create_file_in_bare_repo(mut repo Repo, branch string, file_path string, content string, message string, author string) bool {
185 git_dir := repo.git_dir
186 app.info('Creating file ${file_path} in ${git_dir} on branch ${branch}')
187
188 // Validate untrusted inputs before they reach git. Every git invocation
189 // below passes its arguments as an array (never through a shell), but we
190 // still reject values git itself could treat as flags/refs or that contain
191 // control characters. This guards both the create-file and update-file
192 // routes, which both funnel through here.
193 if !is_safe_ref(branch) {
194 app.warn('Refusing to write file: invalid branch name "${branch}"')
195 return false
196 }
197 if !is_valid_repo_file_path(file_path) {
198 app.warn('Refusing to write file: invalid file path "${file_path}"')
199 return false
200 }
201
202 // Write content to a temp file, then hash it into git
203 tmp_file := os.join_path(os.temp_dir(), 'gitly_newfile_${repo.id}_${os.getpid()}')
204 os.write_file(tmp_file, content) or {
205 app.warn('Failed to write temp file: ${err}')
206 return false
207 }
208 defer {
209 os.rm(tmp_file) or {}
210 }
211
212 // 1. Hash the blob into the object store
213 hash_res := git.Git.exec_in_dir(git_dir, ['hash-object', '-w', tmp_file])
214 if hash_res.exit_code != 0 {
215 app.warn('hash-object failed: ${hash_res.output}')
216 return false
217 }
218 blob_hash := hash_res.output.trim_space()
219 if blob_hash == '' {
220 app.warn('hash-object produced no hash')
221 return false
222 }
223
224 // 2. Find the current tree and parent commit for this branch (both may be
225 // empty when committing to a brand-new branch).
226 tree_res := git.Git.exec_in_dir(git_dir, ['rev-parse', '${branch}^{tree}'])
227 existing_tree := if tree_res.exit_code == 0 { tree_res.output.trim_space() } else { '' }
228 has_existing_tree := existing_tree != ''
229
230 parent_res := git.Git.exec_in_dir(git_dir, ['rev-parse', branch])
231 parent_commit := if parent_res.exit_code == 0 { parent_res.output.trim_space() } else { '' }
232
233 // 3. Build the new tree inside an isolated temp index, so neither the
234 // repo's own index nor a concurrent edit of the same repo is affected.
235 // Starting from an empty index (no read-tree) handles the new-branch
236 // case without needing `mktree`.
237 tmp_index := os.join_path(os.temp_dir(), 'gitly_index_${repo.id}_${os.getpid()}')
238 os.rm(tmp_index) or {}
239 defer {
240 os.rm(tmp_index) or {}
241 }
242 index_env := {
243 'GIT_INDEX_FILE': tmp_index
244 }
245
246 if has_existing_tree {
247 read_res := git.Git.exec_in_dir_with_env(git_dir, ['read-tree', existing_tree], index_env)
248 if read_res.exit_code != 0 {
249 app.warn('read-tree failed: ${read_res.output}')
250 return false
251 }
252 }
253
254 add_res := git.Git.exec_in_dir_with_env(git_dir, ['update-index', '--add', '--cacheinfo',
255 '100644,${blob_hash},${file_path}'], index_env)
256 if add_res.exit_code != 0 {
257 app.warn('update-index failed: ${add_res.output}')
258 return false
259 }
260
261 write_res := git.Git.exec_in_dir_with_env(git_dir, ['write-tree'], index_env)
262 if write_res.exit_code != 0 {
263 app.warn('write-tree failed: ${write_res.output}')
264 return false
265 }
266 new_tree_hash := write_res.output.trim_space()
267 if new_tree_hash == '' {
268 app.warn('Failed to create tree')
269 return false
270 }
271
272 // 4. Create the commit. The message and author are passed as plain
273 // arguments / environment values, so any shell metacharacters they
274 // contain are inert.
275 mut commit_args := ['commit-tree', new_tree_hash]
276 if parent_commit != '' {
277 commit_args << ['-p', parent_commit]
278 }
279 commit_args << ['-m', message]
280 commit_env := {
281 'GIT_AUTHOR_NAME': author
282 'GIT_AUTHOR_EMAIL': '${author}@gitly'
283 'GIT_COMMITTER_NAME': author
284 'GIT_COMMITTER_EMAIL': '${author}@gitly'
285 }
286 r4 := git.Git.exec_in_dir_with_env(git_dir, commit_args, commit_env)
287 if r4.exit_code != 0 {
288 app.warn('commit-tree failed: ${r4.output}')
289 return false
290 }
291 new_commit_hash := r4.output.trim_space()
292
293 // 5. Update the branch ref
294 r5 := git.Git.exec_in_dir(git_dir, ['update-ref', 'refs/heads/${branch}', new_commit_hash])
295 if r5.exit_code != 0 {
296 app.warn('update-ref failed: ${r5.output}')
297 return false
298 }
299
300 app.info('File ${file_path} created with commit ${new_commit_hash}')
301 return true
302}
303
304// is_valid_repo_file_path rejects empty/over-long paths, absolute paths, leading
305// dashes, parent-directory traversal, and any control characters (including NUL
306// and newlines).
307fn is_valid_repo_file_path(path string) bool {
308 if path.len == 0 || path.len > 4096 {
309 return false
310 }
311 if path.starts_with('/') || path.starts_with('-') || path.contains('..') {
312 return false
313 }
314 for c in path {
315 if c < 0x20 || c == 0x7f {
316 return false
317 }
318 }
319 return true
320}
321