166f6e9b Andy McCall 15 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) ;
235
app.repos.create ( & name, Some ( repo_id) ) ? ;
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
261
262
pub struct Update {
263
pub title : String,
264
pub description : String,
265
pub secret : bool ,
266
267
pub files : Vec< FileEdit> ,
268
pub message : String,
269
}
270
271
272
273
pub async fn update ( app : & Shared, account : & Account, row : & RepoRow, u : Update) -> Result< ( ) , DropError> {
274
let ( title, description) = ( u.title.trim ( ) , u.description.trim ( ) ) ;
275
check_text ( title, description) .map_err ( DropError:: Bad) ? ;
276
if ! u.files.is_empty ( ) {
277
if u.files.len ( ) > MAX_FILES {
278
return Err ( bad ( format! ( " A drop takes at most {MAX_FILES} files at a time." ) ) ) ;
279
}
280
let written: Vec< NewFile> = u
281
.files
282
.iter ( )
283
.filter_map ( | e | e.content.as_ref ( ) .map ( | c | NewFile { name: e.path.clone ( ) , content: String :: from_utf8_lossy( c) .into_owned ( ) } ) )
284
.collect ( ) ;
285
for f in & written {
286
check_name ( & f.name) .map_err ( DropError:: Bad) ? ;
287
if f.content.len ( ) > MAX_FILE_BYTES {
288
return Err ( bad ( format! ( " {} is larger than {} KiB; push larger files with oxy." , f.name, MAX_FILE_BYTES / 1024 ) ) ) ;
289
}
290
}
291
scan ( & written) .map_err ( DropError:: Bad) ? ;
292
let signer = signer ( app, account) .await? ;
293
let name = RepoName:: new( & row.owner, & row.name) .ok_or_else ( | | bad ( " not a drop" ) ) ? ;
294
let message = if u.message.trim ( ) .is_empty ( ) {
295
format! ( " Edit {} " , if title.is_empty ( ) { id_of ( row) } else { title } )
296
} else {
297
u.message.trim ( ) .to_string ( )
298
} ;
299
300
if u.files.iter ( ) .any ( | e | e.content.is_none ( ) ) {
301
let ( _ , current) = files ( app, row, " " ) .await? ;
302
let left = current.iter ( ) .filter ( | c | ! u.files.iter ( ) .any ( | e | e.path = = c.path & & e.content.is_none ( ) ) ) .count ( ) ;
303
if left = = 0 & & written.is_empty ( ) {
304
return Err ( bad ( " A drop keeps at least one file; delete the drop instead." ) ) ;
305
}
306
}
307
let b = row.default_bookmark.clone ( ) ;
308
let done = edits:: commit( & app.repos, & name, b.clone ( ) , b, u.files, message, signer)
309
.await
310
.map_err ( | e | DropError:: Bad( format! ( " The drop was not changed: {e} " ) ) ) ? ;
311
recorded ( app, account, row, & done, " drop.edited" , " " ) .await;
312
}
313
db:: repos:: set_drop( & app.db, row.id, title, description, visibility ( u.secret) ) .await? ;
314
if visibility ( u.secret) ! = row.visibility ( ) {
315
db:: accounts:: audit( & app.db, Some ( account) , " drop.visibility" , & row.slug ( ) , if u.secret { " secret" } else { " public" } ) .await.ok ( ) ;
316
}
317
Ok ( ( ) )
318
}
319
320
321
async fn recorded ( app : & Shared, account : & Account, row : & RepoRow, done : & edits:: Committed, action : & str , detail : & str ) {
322
let outcome = db:: repos:: PushOutcome {
323
reference: done.bookmark.clone ( ) ,
324
ok: true ,
325
reason: " web edit" .into ( ) ,
326
old_state: done.old_state.clone ( ) ,
327
new_state: done.new_state.clone ( ) ,
328
heads: done.heads.len ( ) as i64 ,
329
changes: 1 ,
330
} ;
331
db:: repos:: record_push( & app.db, row.id, Some ( ( account.id, & account.username) ) , & [ outcome] ) .await.ok ( ) ;
332
let detail = if detail.is_empty ( ) { done.change.short ( 12 ) } else { format! ( " {detail} {} " , done.change.short ( 12 ) ) } ;
333
db:: accounts:: audit( & app.db, Some ( account) , action, & row.slug ( ) , & detail) .await.ok ( ) ;
334
}
335
336
337
338
pub async fn delete ( app : & Shared, actor : & Account, row : & RepoRow) -> Result< ( ) , DropError> {
339
let name = RepoName:: new( & row.owner, & row.name) .ok_or_else ( | | bad ( " not a drop" ) ) ? ;
340
let _guard = app.repos.exclusive_guard ( & name) .await;
341
match app.repos.trash ( & name) {
342
Ok ( _ ) | Err ( RepoError:: NotFound) => { }
343
Err ( e) => return Err ( e.into ( ) ) ,
344
}
345
db:: repos:: delete( & app.db, row.id) .await? ;
346
db:: accounts:: audit( & app.db, Some ( actor) , " drop.deleted" , & row.slug ( ) , " moved to trash" ) .await.ok ( ) ;
347
Ok ( ( ) )
348
}
349
350
351
352
pub async fn files ( app : & Shared, row : & RepoRow, spec : & str ) -> Result< ( crate:: repos:: view:: RefTarget, Vec< DropFile> ) , RepoError> {
353
let name = RepoName:: new( & row.owner, & row.name) .ok_or ( RepoError:: NotFound) ? ;
354
let spec = if spec.is_empty ( ) { row.default_bookmark.clone ( ) } else { spec.to_string ( ) } ;
355
app.repos
356
.read ( & name, move | repo, cache| {
357
let target = crate :: repos:: view:: resolve( repo, cache, & spec) ? ;
358
let loaded = cache.load ( repo, & target.heads) ? ;
359
let mut out = Vec :: new( ) ;
360
for ( path, f) in & loaded.snapshot.files {
361
let bytes = match ( & f.kind, & f.content) {
362
( NodeKind:: Dir, _ ) | ( _ , FileContent:: Dir) => continue ,
363
( _ , FileContent:: Text { bytes, .. } ) => bytes.clone ( ) ,
364
( _ , FileContent:: Opaque { blobs } ) => match blobs.first ( ) {
365
Some ( b) => repo.read_blob ( b) ? ,
366
None => Vec :: new( ) ,
367
} ,
368
} ;
369
let size = bytes.len ( ) as u64 ;
370
let text = if size < = SHOWN_BYTES & & ! crate :: repos:: view:: is_binary( & bytes) { String :: from_utf8( bytes) .ok ( ) } else { None } ;
371
out.push ( DropFile { path: path.clone ( ) , size, text } ) ;
372
}
373
Ok ( ( target, out) )
374
} )
375
.await
376
}
377
378
379
pub async fn find ( app : & Shared, owner : & str , id : & str ) -> sqlx:: Result< Option< RepoRow> > {
380
let Some ( name) = RepoName:: drop( owner, id) else { return Ok ( None ) } ;
381
Ok ( db:: repos:: get( & app.db, & name.owner, & name.name) .await? .filter ( | r | r.is_drop ( ) ) )
382
}
383
384
385
pub fn may_edit ( account : Option< & Account> , row : & RepoRow) -> bool {
386
account.is_some_and ( | a | ! a.blocked ( ) & & a.username.eq_ignore_ascii_case ( & row.owner) )
387
}
388
389
390
391
pub fn may_delete ( account : Option< & Account> , row : & RepoRow) -> bool {
392
may_edit ( account, row) | | account.is_some_and ( | a | ! a.blocked ( ) & & a.is_admin)
393
}
394
395
396
397
pub fn listed_to ( account : Option< & Account> , row : & RepoRow) -> bool {
398
row.visibility ( ) = = Visibility:: Public | | may_delete ( account, row)
399
}
400
401
402
403
404
pub fn edits_between ( current : & [DropFile], wanted : & [NewFile]) -> Vec< FileEdit> {
405
let mut out = Vec :: new( ) ;
406
for c in current.iter ( ) .filter ( | c | c.text.is_some ( ) ) {
407
if ! wanted.iter ( ) .any ( | w | w.name = = c.path ) {
408
out.push ( FileEdit { path: c.path.clone ( ) , content: None } ) ;
409
}
410
}
411
for w in wanted {
412
let same = current.iter ( ) .any ( | c | c.path = = w.name & & c.text.as_deref ( ) = = Some ( w.content.as_str ( ) ) ) ;
413
if ! same {
414
out.push ( FileEdit { path: w.name.clone ( ) , content: Some ( w.content.clone ( ) .into_bytes ( ) ) } ) ;
415
}
416
}
417
out
418
}