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