166f6e9b Andy McCall 16 days ago
1
2
3
4
5
6
7
8
9
10
11
12
13
use crate :: db:: { self , accounts:: Account, repos:: RepoRow} ;
14
use crate :: repos:: edits:: { self , Signer} ;
15
use crate :: repos:: { RepoError, RepoName} ;
16
use crate :: Shared;
17
use hub_api:: Visibility;
18
use oxy_core:: objects:: NodeKind;
19
use oxy_core:: repo:: FileEdit;
20
use oxy_core:: state:: FileContent;
21
22
23
pub const MAX_FILES : usize = 50 ;
24
25
pub const MAX_FILE_BYTES : usize = 1 < < 20 ;
26
27
pub const MAX_TITLE : usize = 200 ;
28
pub const MAX_DESCRIPTION : usize = 2000 ;
29
30
const SHOWN_BYTES : u64 = 512 * 1024 ;
31
32
33
# [ derive ( Debug ) ]
34
pub enum DropError {
35
36
Bad( String ) ,
37
Repo( RepoError) ,
38
Db( sqlx:: Error) ,
39
}
40
41
impl std:: fmt:: Display for DropError {
42
fn fmt ( & self , f : & mut std:: fmt:: Formatter< '_ > ) -> std:: fmt:: Result {
43
match self {
44
DropError:: Bad( m) => f.write_str ( m) ,
45
DropError:: Repo( e) => write! ( f, " {e} " ) ,
46
DropError:: Db( e) => write! ( f, " {e} " ) ,
47
}
48
}
49
}
50
51
impl From< sqlx:: Error> for DropError {
52
fn from ( e : sqlx:: Error) -> DropError {
53
DropError:: Db( e)
54
}
55
}
56
57
impl From< RepoError> for DropError {
58
fn from ( e : RepoError) -> DropError {
59
DropError:: Repo( e)
60
}
61
}
62
63
fn bad ( msg : impl Into< String> ) -> DropError {
64
DropError:: Bad( msg.into ( ) )
65
}
66
67
68
pub fn visibility_label ( row : & RepoRow) -> & 'static str {
69
if row.visibility ( ) = = Visibility:: Public {
70
" public"
71
} else {
72
" secret"
73
}
74
}
75
76
fn visibility ( secret : bool ) -> Visibility {
77
if secret {
78
Visibility:: Private
79
} else {
80
Visibility:: Public
81
}
82
}
83
84
85
pub fn id_of ( row : & RepoRow) -> & str {
86
row.name.split_once ( ' /' ) .map ( | ( _, id ) | id ) .unwrap_or ( & row.name)
87
}
88
89
90
pub fn url_of ( row : & RepoRow) -> String {
91
format! ( " /{} /{} " , row.owner, row.name)
92
}
93
94
95
pub fn shown_title ( row : & RepoRow, files : & [DropFile]) -> String {
96
if ! row.title.is_empty ( ) {
97
row.title.clone ( )
98
} else if let Some ( f) = files.first ( ) {
99
f.path.clone ( )
100
} else {
101
id_of ( row) .to_string ( )
102
}
103
}
104
105
106
# [ derive ( Clone, Debug ) ]
107
pub struct DropFile {
108
pub path : String,
109
pub size : u64 ,
110
111
pub text : Option< String> ,
112
}
113
114
115
# [ derive ( Clone, Debug ) ]
116
pub struct NewFile {
117
pub name : String,
118
pub content : String,
119
}
120
121
122
pub fn check_name ( name : & str ) -> Result< String, String> {
123
let name = name.trim ( ) .trim_matches ( ' /' ) ;
124
if name.is_empty ( ) {
125
return Err ( " Every file needs a name." .into ( ) ) ;
126
}
127
if name.len ( ) > 255 {
128
return Err ( format! ( " {name:.40}…: a file name is at most 255 bytes." ) ) ;
129
}
130
if name.chars ( ) .any ( | c | c.is_control ( ) | | c = = ' \\ ' )
131
| | name.split ( ' /' ) .any ( | p | p.is_empty ( ) | | p = = " ." | | p = = " .." | | p = = oxy_core:: repo:: OXY_DIR | | p.trim ( ) ! = p )
132
{
133
return Err ( format! ( " {name:?} is not a file name: use letters, digits and punctuation, with / between folders." ) ) ;
134
}
135
Ok ( name.to_string ( ) )
136
}
137
138
139
140
pub fn check_files ( files : & [NewFile], allow_empty : bool ) -> Result< Vec< NewFile> , String> {
141
if files.is_empty ( ) & & ! allow_empty {
142
return Err ( " A drop needs at least one file." .into ( ) ) ;
143
}
144
if files.len ( ) > MAX_FILES {
145
return Err ( format! ( " A drop takes at most {MAX_FILES} files at a time." ) ) ;
146
}
147
let mut out: Vec< NewFile> = Vec :: with_capacity( files.len ( ) ) ;
148
for f in files {
149
let name = check_name ( & f.name) ? ;
150
if f.content.len ( ) > MAX_FILE_BYTES {
151
return Err ( format! ( " {name} is larger than {} KiB; push larger files with oxy." , MAX_FILE_BYTES / 1024 ) ) ;
152
}
153
if out.iter ( ) .any ( | o | o.name = = name ) {
154
return Err ( format! ( " {name} is named twice." ) ) ;
155
}
156
157
out.push ( NewFile { name, content: f.content.replace ( " \r \n " , " \n " ) } ) ;
158
}
159
scan ( & out) ? ;
160
Ok ( out)
161
}
162
163
164
165
166
pub fn scan ( files : & [NewFile]) -> Result< ( ) , String> {
167
let rules = crate :: push_protection:: rules( ) ;
168
let mut found = Vec :: new( ) ;
169
for f in files {
170
for h in crate :: push_protection:: scan_text( rules, f.content.as_bytes ( ) ) {
171
let rule = & rules.rules[ h.rule] ;
172
found.push ( format! ( " {} :{} : {} (fingerprint {} )" , f.name, h.line, rule.description, h.fingerprint) ) ;
173
}
174
}
175
if found.is_empty ( ) {
176
return Ok ( ( ) ) ;
177
}
178
Err ( format! (
179
" Push protection refused this: {} like {} , and nothing was saved. {} Take {} out (read credentials from the environment or a secret store), and if {} real, revoke or rotate {} ." ,
180
if found.len ( ) = = 1 { " a value looks" .to_string ( ) } else { format! ( " {} values look" , found.len ( ) ) } ,
181
if found.len ( ) = = 1 { " a secret" } else { " secrets" } ,
182
found.join ( " ; " ) ,
183
if found.len ( ) = = 1 { " it" } else { " them" } ,
184
if found.len ( ) = = 1 { " it is" } else { " one is" } ,
185
if found.len ( ) = = 1 { " it" } else { " it as well" } ,
186
) )
187
}
188
189
fn check_text ( title : & str , description : & str ) -> Result< ( ) , String> {
190
if title.chars ( ) .count ( ) > MAX_TITLE {
191
return Err ( format! ( " A title is at most {MAX_TITLE} characters." ) ) ;
192
}
193
if description.chars ( ) .count ( ) > MAX_DESCRIPTION {
194
return Err ( format! ( " A description is at most {MAX_DESCRIPTION} characters." ) ) ;
195
}
196
Ok ( ( ) )
197
}
198
199
200
async fn signer ( app : & Shared, account : & Account) -> Result< Signer, DropError> {
201
crate :: web:: edit:: signer_for( app, account.id) .await.map_err ( DropError:: Bad)
202
}
203
204
205
206
207
pub async fn create (
208
app : & Shared,
209
account : & Account,
210
title : & str ,
211
description : & str ,
212
secret : bool ,
213
files : & [NewFile],
214
) -> Result< RepoRow, DropError> {
215
if account.blocked ( ) {
216
return Err ( bad ( " This account cannot make drops." ) ) ;
217
}
218
let ( title, description) = ( title.trim ( ) , description.trim ( ) ) ;
219
check_text ( title, description) .map_err ( DropError:: Bad) ? ;
220
let files = check_files ( files, false ) .map_err ( DropError:: Bad) ? ;
221
let signer = signer ( app, account) .await? ;
222
223
let mut found = None ;
224
for _ in 0 .. 8 {
225
let id = crate :: auth:: random_hex( 12 ) ;
226
let Some ( name) = RepoName:: drop( & account.username, & id) else { break } ;
227
if db:: repos:: drop_by_id( & app.db, & id) .await? .is_none ( ) & & ! app.repos.exists ( & name) {
228
found = Some ( name) ;
229
break ;
230
}
231
}
232
let Some ( name) = found else { return Err ( bad ( " No free drop id was found; try again." ) ) } ;
233
let mut repo_id = [ 0 u8 ; 16 ] ;
234
rand:: RngCore:: fill_bytes( & mut rand:: rngs:: OsRng, & mut repo_id) ;
15d3f6f7 Andy McCall 9 days ago ↶
235
app.repos.create_async ( & name, Some ( repo_id) ) .await? ;
166f6e9b Andy McCall 16 days ago
236
let id = name.drop_id ( ) .unwrap_or_default ( ) .to_string ( ) ;
237
let row = match db:: repos:: create_drop( & app.db, & account.username, & id, title, description, visibility ( secret) ) .await {
238
Ok ( row) => row,
239
Err ( e) => {
240
let _ = std:: fs:: remove_dir_all( app.repos.dir ( & name) ) ;
241
return Err ( e.into ( ) ) ;
242
}
243
} ;
244
let edits: Vec< FileEdit> =
245
files.iter ( ) .map ( | f | FileEdit { path: f.name.clone ( ) , content: Some ( f.content.clone ( ) .into_bytes ( ) ) } ) .collect ( ) ;
246
let message = if title.is_empty ( ) { " Create the drop" .to_string ( ) } else { format! ( " Create {title} " ) } ;
247
let b = row.default_bookmark.clone ( ) ;
248
match edits:: commit( & app.repos, & name, b.clone ( ) , b, edits, message, signer) .await {
249
Ok ( done) => {
250
recorded ( app, account, & row, & done, " drop.created" , visibility_label ( & row) ) .await;
251
Ok ( db:: repos:: get_by_id( & app.db, row.id) .await? .unwrap_or ( row) )
252
}
253
Err ( e) => {
254
db:: repos:: delete( & app.db, row.id) .await.ok ( ) ;
255
let _ = std:: fs:: remove_dir_all( app.repos.dir ( & name) ) ;
256
Err ( DropError:: Bad( format! ( " The drop was not made: {e} " ) ) )
257
}
258
}
259
}
260
adbc38e7 Andy McCall 15 days ago ↶
261
262
263
264
pub async fn fork ( app : & Shared, account : & Account, source : & RepoRow) -> Result< RepoRow, DropError> {
265
if account.blocked ( ) {
266
return Err ( bad ( " This account cannot make drops." ) ) ;
267
}
268
let Some ( from) = RepoName:: new( & source.owner, & source.name) else { return Err ( bad ( " That is not a drop." ) ) } ;
269
let mut found = None ;
270
for _ in 0 .. 8 {
271
let id = crate :: auth:: random_hex( 12 ) ;
272
let Some ( name) = RepoName:: drop( & account.username, & id) else { break } ;
273
if db:: repos:: drop_by_id( & app.db, & id) .await? .is_none ( ) & & ! app.repos.exists ( & name) {
274
found = Some ( name) ;
275
break ;
276
}
277
}
278
let Some ( name) = found else { return Err ( bad ( " No free drop id was found; try again." ) ) } ;
279
app.repos.fork ( & from, & name) .await? ;
280
let id = name.drop_id ( ) .unwrap_or_default ( ) .to_string ( ) ;
281
let row = match db:: repos:: create_drop( & app.db, & account.username, & id, & source.title, & source.description, source.visibility ( ) ) .await {
282
Ok ( row) => row,
283
Err ( e) => {
284
let _ = std:: fs:: remove_dir_all( app.repos.dir ( & name) ) ;
285
return Err ( e.into ( ) ) ;
286
}
287
} ;
288
db:: repos:: set_forked_from( & app.db, row.id, source.id) .await? ;
289
db:: accounts:: audit( & app.db, Some ( account) , " drop.forked" , & url_of ( & row) , & url_of ( source) ) .await.ok ( ) ;
290
Ok ( db:: repos:: get_by_id( & app.db, row.id) .await? .unwrap_or ( row) )
291
}
292
166f6e9b Andy McCall 16 days ago
293
294
pub struct Update {
295
pub title : String,
296
pub description : String,
297
pub secret : bool ,
298
299
pub files : Vec< FileEdit> ,
300
pub message : String,
301
}
302
303
304
305
pub async fn update ( app : & Shared, account : & Account, row : & RepoRow, u : Update) -> Result< ( ) , DropError> {
306
let ( title, description) = ( u.title.trim ( ) , u.description.trim ( ) ) ;
307
check_text ( title, description) .map_err ( DropError:: Bad) ? ;
308
if ! u.files.is_empty ( ) {
309
if u.files.len ( ) > MAX_FILES {
310
return Err ( bad ( format! ( " A drop takes at most {MAX_FILES} files at a time." ) ) ) ;
311
}
312
let written: Vec< NewFile> = u
313
.files
314
.iter ( )
315
.filter_map ( | e | e.content.as_ref ( ) .map ( | c | NewFile { name: e.path.clone ( ) , content: String :: from_utf8_lossy( c) .into_owned ( ) } ) )
316
.collect ( ) ;
317
for f in & written {
318
check_name ( & f.name) .map_err ( DropError:: Bad) ? ;
319
if f.content.len ( ) > MAX_FILE_BYTES {
320
return Err ( bad ( format! ( " {} is larger than {} KiB; push larger files with oxy." , f.name, MAX_FILE_BYTES / 1024 ) ) ) ;
321
}
322
}
323
scan ( & written) .map_err ( DropError:: Bad) ? ;
324
let signer = signer ( app, account) .await? ;
325
let name = RepoName:: new( & row.owner, & row.name) .ok_or_else ( | | bad ( " not a drop" ) ) ? ;
326
let message = if u.message.trim ( ) .is_empty ( ) {
327
format! ( " Edit {} " , if title.is_empty ( ) { id_of ( row) } else { title } )
328
} else {
329
u.message.trim ( ) .to_string ( )
330
} ;
331
332
if u.files.iter ( ) .any ( | e | e.content.is_none ( ) ) {
333
let ( _ , current) = files ( app, row, " " ) .await? ;
334
let left = current.iter ( ) .filter ( | c | ! u.files.iter ( ) .any ( | e | e.path = = c.path & & e.content.is_none ( ) ) ) .count ( ) ;
335
if left = = 0 & & written.is_empty ( ) {
336
return Err ( bad ( " A drop keeps at least one file; delete the drop instead." ) ) ;
337
}
338
}
339
let b = row.default_bookmark.clone ( ) ;
340
let done = edits:: commit( & app.repos, & name, b.clone ( ) , b, u.files, message, signer)
341
.await
342
.map_err ( | e | DropError:: Bad( format! ( " The drop was not changed: {e} " ) ) ) ? ;
343
recorded ( app, account, row, & done, " drop.edited" , " " ) .await;
344
}
345
db:: repos:: set_drop( & app.db, row.id, title, description, visibility ( u.secret) ) .await? ;
346
if visibility ( u.secret) ! = row.visibility ( ) {
347
db:: accounts:: audit( & app.db, Some ( account) , " drop.visibility" , & row.slug ( ) , if u.secret { " secret" } else { " public" } ) .await.ok ( ) ;
348
}
349
Ok ( ( ) )
350
}
351
352
353
async fn recorded ( app : & Shared, account : & Account, row : & RepoRow, done : & edits:: Committed, action : & str , detail : & str ) {
354
let outcome = db:: repos:: PushOutcome {
355
reference: done.bookmark.clone ( ) ,
356
ok: true ,
357
reason: " web edit" .into ( ) ,
358
old_state: done.old_state.clone ( ) ,
359
new_state: done.new_state.clone ( ) ,
360
heads: done.heads.len ( ) as i64 ,
361
changes: 1 ,
362
} ;
363
db:: repos:: record_push( & app.db, row.id, Some ( ( account.id, & account.username) ) , & [ outcome] ) .await.ok ( ) ;
364
let detail = if detail.is_empty ( ) { done.change.short ( 12 ) } else { format! ( " {detail} {} " , done.change.short ( 12 ) ) } ;
365
db:: accounts:: audit( & app.db, Some ( account) , action, & row.slug ( ) , & detail) .await.ok ( ) ;
366
}
367
368
369
370
pub async fn delete ( app : & Shared, actor : & Account, row : & RepoRow) -> Result< ( ) , DropError> {
371
let name = RepoName:: new( & row.owner, & row.name) .ok_or_else ( | | bad ( " not a drop" ) ) ? ;
372
let _guard = app.repos.exclusive_guard ( & name) .await;
373
match app.repos.trash ( & name) {
374
Ok ( _ ) | Err ( RepoError:: NotFound) => { }
375
Err ( e) => return Err ( e.into ( ) ) ,
376
}
377
db:: repos:: delete( & app.db, row.id) .await? ;
378
db:: accounts:: audit( & app.db, Some ( actor) , " drop.deleted" , & row.slug ( ) , " moved to trash" ) .await.ok ( ) ;
379
Ok ( ( ) )
380
}
381
382
383
384
pub async fn files ( app : & Shared, row : & RepoRow, spec : & str ) -> Result< ( crate:: repos:: view:: RefTarget, Vec< DropFile> ) , RepoError> {
385
let name = RepoName:: new( & row.owner, & row.name) .ok_or ( RepoError:: NotFound) ? ;
386
let spec = if spec.is_empty ( ) { row.default_bookmark.clone ( ) } else { spec.to_string ( ) } ;
387
app.repos
388
.read ( & name, move | repo, cache| {
389
let target = crate :: repos:: view:: resolve( repo, cache, & spec) ? ;
390
let loaded = cache.load ( repo, & target.heads) ? ;
391
let mut out = Vec :: new( ) ;
392
for ( path, f) in & loaded.snapshot.files {
393
let bytes = match ( & f.kind, & f.content) {
394
( NodeKind:: Dir, _ ) | ( _ , FileContent:: Dir) => continue ,
395
( _ , FileContent:: Text { bytes, .. } ) => bytes.clone ( ) ,
396
( _ , FileContent:: Opaque { blobs } ) => match blobs.first ( ) {
397
Some ( b) => repo.read_blob ( b) ? ,
398
None => Vec :: new( ) ,
399
} ,
400
} ;
401
let size = bytes.len ( ) as u64 ;
402
let text = if size < = SHOWN_BYTES & & ! crate :: repos:: view:: is_binary( & bytes) { String :: from_utf8( bytes) .ok ( ) } else { None } ;
403
out.push ( DropFile { path: path.clone ( ) , size, text } ) ;
404
}
405
Ok ( ( target, out) )
406
} )
407
.await
408
}
409
410
411
pub async fn find ( app : & Shared, owner : & str , id : & str ) -> sqlx:: Result< Option< RepoRow> > {
412
let Some ( name) = RepoName:: drop( owner, id) else { return Ok ( None ) } ;
413
Ok ( db:: repos:: get( & app.db, & name.owner, & name.name) .await? .filter ( | r | r.is_drop ( ) ) )
414
}
415
416
417
pub fn may_edit ( account : Option< & Account> , row : & RepoRow) -> bool {
418
account.is_some_and ( | a | ! a.blocked ( ) & & a.username.eq_ignore_ascii_case ( & row.owner) )
419
}
420
421
422
423
pub fn may_delete ( account : Option< & Account> , row : & RepoRow) -> bool {
424
may_edit ( account, row) | | account.is_some_and ( | a | ! a.blocked ( ) & & a.is_admin)
425
}
426
427
428
429
pub fn listed_to ( account : Option< & Account> , row : & RepoRow) -> bool {
430
row.visibility ( ) = = Visibility:: Public | | may_delete ( account, row)
431
}
432
433
434
435
436
pub fn edits_between ( current : & [DropFile], wanted : & [NewFile]) -> Vec< FileEdit> {
437
let mut out = Vec :: new( ) ;
438
for c in current.iter ( ) .filter ( | c | c.text.is_some ( ) ) {
439
if ! wanted.iter ( ) .any ( | w | w.name = = c.path ) {
440
out.push ( FileEdit { path: c.path.clone ( ) , content: None } ) ;
441
}
442
}
443
for w in wanted {
444
let same = current.iter ( ) .any ( | c | c.path = = w.name & & c.text.as_deref ( ) = = Some ( w.content.as_str ( ) ) ) ;
445
if ! same {
446
out.push ( FileEdit { path: w.name.clone ( ) , content: Some ( w.content.clone ( ) .into_bytes ( ) ) } ) ;
447
}
448
}
449
out
450
}