14530604 Andy McCall 1 month ago ↶
1
2
3
4
5
6
use super :: settings:: { csrf, require} ;
7
use super :: { lookup_role, Error, Page, RepoShell, Shell} ;
8
use crate :: auth:: { Access, Viewer} ;
9
use crate :: db:: { self , accounts} ;
10
use crate :: repos:: edits:: { self , Signer} ;
11
use crate :: repos:: view:: { self , FileBody} ;
12
use crate :: Shared;
13
use askama:: Template;
14
use axum:: extract:: { Form, Path, Query, State} ;
15
use axum:: response:: { IntoResponse, Redirect, Response} ;
16
use oxy_core:: identity:: Identity;
17
use oxy_core:: repo:: FileEdit;
18
use serde:: Deserialize;
19
20
21
pub async fn delegated_identity ( app : & Shared, account_id : i64 ) -> Option< Identity> {
22
let linked = accounts:: identities_of( & app.db, account_id) .await.ok ( ) ? ;
23
linked.into_iter ( ) .filter_map ( | l | Identity:: decode( & l.object) .ok ( ) ) .find ( | i | i.delegates.contains ( & app.hub_key.key) )
24
}
25
26
27
pub async fn signer_for ( app : & Shared, account_id : i64 ) -> Result< Signer, String> {
28
let author = delegated_identity ( app, account_id) .await.ok_or_else ( | | {
29
" You have not delegated to this hub's key yet. From a checkout with your identity: download the hub key from /.well-known/oxyhub-key, run `oxy identity delegate add oxyhub-key.identity`, then link the new identity under Settings › Keys (`oxy identity export`)." .to_string ( )
30
} ) ? ;
31
let ( hub_key, hub_secret) = crate :: keys:: load_secret( & app.config.keys_dir ( ) ) .map_err ( | e | format! ( " the hub key is unavailable: {e} " ) ) ? ;
32
Ok ( Signer { author, hub_key, hub_secret, hub_identity: app.hub_key.identity.clone ( ) } )
33
}
34
35
# [ derive ( Template ) ]
36
# [ template ( path = " edit.html" ) ]
37
pub struct EditPage {
38
shell : Shell,
39
repo : RepoShell,
40
r : String,
41
path : String,
42
content : String,
43
new_file : bool ,
44
is_bookmark : bool ,
45
conflicted : bool ,
46
delegation : Option< String> ,
47
error : Option< String> ,
48
message : String,
49
}
50
51
# [ derive ( Deserialize ) ]
52
pub struct NewQuery {
53
#[serde(default)]
54
dir : String,
55
}
56
57
58
# [ allow ( clippy::too_many_arguments ) ]
59
async fn page (
60
app : & Shared,
61
viewer : & Viewer,
62
owner : & str ,
63
name : & str ,
64
r : & str ,
65
path : Option< & str > ,
66
dir : & str ,
67
error : Option< String> ,
68
content : Option< String> ,
69
) -> Result< Page< EditPage> , Error> {
70
let shell = Shell:: new( app, & format! ( " /{owner} /{name} /edit/{r} " ) , viewer) ;
71
let me = require ( viewer, & shell) ? ;
72
let ( repo, row, role) = lookup_role ( app, & shell, viewer, owner, name, Access:: Write) .await? ;
73
let limit = app.config.limits.render_limit.0 ;
74
let spec = r.to_string ( ) ;
75
let p = path.map ( String :: from) ;
76
let ( is_bookmark, file) = app
77
.repos
78
.read ( & repo, move | repo, cache| {
79
let target = view:: resolve( repo, cache, & spec) ? ;
80
let is_bookmark = target.kind = = view:: RefKind:: Bookmark;
81
let file = match & p {
82
Some ( p) => Some ( view:: file( repo, cache, target, p, limit) ? ) ,
83
None => None ,
84
} ;
85
Ok ( ( is_bookmark, file) )
86
} )
87
.await
88
.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
89
let ( existing, conflicted) = match & file {
90
Some ( f) => match & f.body {
91
FileBody:: Text( t) => ( t.clone ( ) , f.conflicted) ,
92
_ => return Err ( Error:: bad( shell, " Only text files within the render limit can be edited in the browser." ) ) ,
93
} ,
94
None => ( String :: new( ) , false ) ,
95
} ;
96
let delegation = signer_for ( app, me.account.id) .await.err ( ) ;
97
let new_file = path.is_none ( ) ;
98
let default_path = if dir.is_empty ( ) { String :: new( ) } else { format! ( " {} /" , dir.trim_matches ( ' /' ) ) } ;
99
Ok ( Page( EditPage {
100
shell,
101
repo: RepoShell:: new( app, & row, " code" ) .with_role ( role) ,
102
r: r.to_string ( ) ,
103
path: path.map ( String :: from) .unwrap_or ( default_path) ,
104
content: content.unwrap_or ( existing) ,
105
new_file,
106
is_bookmark,
107
conflicted,
108
delegation,
109
error,
110
message: String :: new( ) ,
111
} ) )
112
}
113
114
pub async fn edit_form (
115
State( app ) : State< Shared> ,
116
viewer : Viewer,
117
Path( (owner , name , r , path ) ) : Path< ( String, String, String, String) > ,
118
) -> Result< Page< EditPage> , Error> {
119
page ( & app, & viewer, & owner, & name, & r, Some ( & path) , " " , None , None ) .await
120
}
121
122
pub async fn new_form (
123
State( app ) : State< Shared> ,
124
viewer : Viewer,
125
Path( (owner , name , r ) ) : Path< ( String, String, String) > ,
126
Query( q) : Query< NewQuery> ,
127
) -> Result< Page< EditPage> , Error> {
128
page ( & app, & viewer, & owner, & name, & r, None , & q.dir, None , None ) .await
129
}
130
131
# [ derive ( Deserialize ) ]
132
pub struct SaveForm {
133
#[serde(rename = "_csrf")]
134
csrf_token : String,
135
path : String,
136
#[serde(default)]
137
original : String,
138
#[serde(default)]
139
content : String,
140
message : String,
141
142
#[serde(default = "commit_mode")]
143
mode : String,
144
#[serde(default)]
145
new_bookmark : String,
146
#[serde(default)]
147
delete : Option< String> ,
148
}
149
150
fn commit_mode ( ) -> String {
151
" commit" .into ( )
152
}
153
154
pub async fn save (
155
State( app ) : State< Shared> ,
156
viewer : Viewer,
157
Path( (owner , name , r ) ) : Path< ( String, String, String) > ,
158
Form( f) : Form< SaveForm> ,
159
) -> Result< Response, Error> {
160
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /edit/{r} " ) , & viewer) ;
161
let me = require ( & viewer, & shell) ? ;
162
csrf ( & viewer, & f.csrf_token, & shell) ? ;
163
let ( repo, row, _ ) = lookup_role ( & app, & shell, & viewer, & owner, & name, Access:: Write) .await? ;
164
let again = | msg : String| {
165
let ( app, viewer, owner, name, r) = ( app.clone ( ) , viewer.clone ( ) , owner.clone ( ) , name.clone ( ) , r.clone ( ) ) ;
166
let ( orig, content) = ( f.original.clone ( ) , f.content.clone ( ) ) ;
167
async move {
168
page ( & app, & viewer, & owner, & name, & r, if orig.is_empty ( ) { None } else { Some ( orig.as_str ( ) ) } , " " , Some ( msg) , Some ( content) )
169
.await
170
.map ( IntoResponse:: into_response)
171
}
172
} ;
173
let signer = match signer_for ( & app, me.account.id) .await {
174
Ok ( s) => s,
175
Err ( msg) => return again ( msg) .await,
176
} ;
177
let path = f.path.trim ( ) .trim_matches ( ' /' ) .to_string ( ) ;
178
if path.is_empty ( ) {
179
return again ( " Name the file." .into ( ) ) .await;
180
}
181
let message = f.message.trim ( ) .to_string ( ) ;
182
if message.is_empty ( ) {
183
return again ( " Describe the change." .into ( ) ) .await;
184
}
185
186
let content = f.content.replace ( " \r \n " , " \n " ) ;
187
let mut changes = vec! [ ] ;
188
if f.delete.is_some ( ) {
189
changes.push ( FileEdit {
190
path: if f.original.is_empty ( ) { path.clone ( ) } else { f.original.trim_matches ( ' /' ) .to_string ( ) } ,
191
content: None ,
192
} ) ;
193
} else {
194
if ! f.original.is_empty ( ) & & f.original.trim_matches ( ' /' ) ! = path {
195
196
changes.push ( FileEdit { path: f.original.trim_matches ( ' /' ) .to_string ( ) , content: None } ) ;
197
}
198
changes.push ( FileEdit { path: path.clone ( ) , content: Some ( content.into_bytes ( ) ) } ) ;
199
}
200
let target = if f.mode = = " propose" {
201
let b = f.new_bookmark.trim ( ) ;
202
let b = if b.is_empty ( ) { format! ( " {} -patch-{} " , me.account.username, crate :: auth:: random_hex( 3 ) ) } else { b.to_string ( ) } ;
203
if ! crate :: repos:: names:: valid_segment( & b) {
204
return again ( " A bookmark name is letters, digits, dots, dashes and underscores." .into ( ) ) .await;
205
}
206
b
207
} else {
208
r.clone ( )
209
} ;
210
let done = match edits:: commit( & app.repos, & repo, r.clone ( ) , target.clone ( ) , changes, message.clone ( ) , signer) .await {
211
Ok ( d) => d,
212
Err ( e) => return again ( e.to_string ( ) ) .await,
213
} ;
214
215
let outcome = db:: repos:: PushOutcome {
216
reference: done.bookmark.clone ( ) ,
217
ok: true ,
218
reason: " web edit" .into ( ) ,
219
old_state: done.old_state.clone ( ) ,
220
new_state: done.new_state.clone ( ) ,
221
heads: done.heads.len ( ) as i64 ,
222
changes: 1 ,
223
} ;
224
db:: repos:: record_push( & app.db, row.id, Some ( ( me.account.id, & me.account.username) ) , & [ outcome] ) .await.ok ( ) ;
225
crate :: hooks:: emit( & app, & row, " push" , & me.account.username, serde_json:: json! ( { " updates" : [ { " reference" : done.bookmark, " old_state" : done.old_state, " new_state" : done.new_state, " heads" : done.heads.len ( ) , " changes" : 1 } ] , " via" : " web" } ) ) .await;
226
if done.bookmark = = row.default_bookmark {
227
super :: issues:: close_from_messages( & app, & row, & [ ( done.change.to_hex ( ) , message.clone ( ) ) ] ) .await;
3f4b0ea6 Andy McCall 1 month ago ↶
228
crate :: mirror:: refresh( & app, & row) .await;
14530604 Andy McCall 1 month ago ↶
229
}
230
accounts:: audit(
231
& app.db,
232
Some ( & me.account) ,
233
" web.edit" ,
234
& format! ( " {owner} /{name} " ) ,
235
& format! ( " {} {} " , done.bookmark, done.change.short ( 12 ) ) ,
236
)
237
.await
238
.ok ( ) ;
239
if f.mode = = " propose" {
240
match super :: proposals:: open_proposal( & app, & repo, & row, & me.account, & message, " " , & r, & done.bookmark, false ) .await {
241
Ok ( p) => return Ok ( Redirect:: to( & format! ( " /{owner} /{name} /proposal/{} " , p.number) ) .into_response ( ) ) ,
242
Err ( msg) => {
243
return Ok ( Redirect:: to( & format! ( " /{owner} /{name} /tree/{} ?notice={} " , done.bookmark, super :: settings:: urlencode( & msg) ) )
244
.into_response ( ) )
245
}
246
}
247
}
248
let to = if f.delete.is_some ( ) {
249
format! ( " /{owner} /{name} /tree/{} " , done.bookmark)
250
} else {
251
format! ( " /{owner} /{name} /file/{} /{} " , done.bookmark, path)
252
} ;
253
Ok ( Redirect:: to( & to) .into_response ( ) )
254
}