1use std::env;
2
3use crate::package::PackageVersion;
4use crate::project::project_toml::RemoteProjectTomlValidationError;
5use crate::rockspec::Rockspec;
6use crate::TOOL_VERSION;
7use crate::{config::Config, project::Project};
8
9use reqwest::StatusCode;
10use reqwest::{
11 multipart::{Form, Part},
12 Client,
13};
14use serde::Deserialize;
15use serde_enum_str::Serialize_enum_str;
16use thiserror::Error;
17use url::Url;
18
19#[cfg(not(target_env = "msvc"))]
20use gpgme::{Context, Data};
21#[cfg(not(target_env = "msvc"))]
22use std::io::Read;
23
24pub struct ProjectUpload<'a> {
27 project: Project,
28 api_key: Option<ApiKey>,
29 sign_protocol: SignatureProtocol,
30 config: &'a Config,
31}
32
33impl<'a> ProjectUpload<'a> {
34 pub fn new(project: Project, config: &'a Config) -> Self {
36 Self {
37 project,
38 api_key: None,
39 sign_protocol: SignatureProtocol::default(),
40 config,
41 }
42 }
43
44 pub fn api_key(self, api_key: ApiKey) -> Self {
46 Self {
47 api_key: Some(api_key),
48 ..self
49 }
50 }
51
52 pub fn sign_protocol(self, sign_protocol: SignatureProtocol) -> Self {
54 Self {
55 sign_protocol,
56 ..self
57 }
58 }
59
60 pub async fn upload_to_luarocks(self) -> Result<(), UploadError> {
62 let api_key = self.api_key.unwrap_or(ApiKey::new()?);
63 upload_from_project(&self.project, &api_key, self.sign_protocol, self.config).await
64 }
65}
66
67#[derive(Deserialize, Debug)]
68pub struct VersionCheckResponse {
69 version: String,
70}
71
72#[derive(Error, Debug)]
73pub enum ToolCheckError {
74 #[error("error parsing tool check URL: {0}")]
75 ParseError(#[from] url::ParseError),
76 #[error(transparent)]
77 Request(#[from] reqwest::Error),
78 #[error("`lux` is out of date with {0}'s expected tool version! `lux` is at version {TOOL_VERSION}, server is at {server_version}", server_version = _1.version)]
79 ToolOutdated(String, VersionCheckResponse),
80}
81
82#[derive(Error, Debug)]
83pub enum UserCheckError {
84 #[error("error parsing user check URL: {0}")]
85 ParseError(#[from] url::ParseError),
86 #[error(transparent)]
87 Request(#[from] reqwest::Error),
88 #[error("invalid API key provided")]
89 UserNotFound,
90 #[error("server {0} responded with error status: {1}")]
91 Server(Url, StatusCode),
92}
93
94#[derive(Error, Debug)]
95#[error("could not check rock status on server: {0}")]
96pub enum RockCheckError {
97 #[error(transparent)]
98 ParseError(#[from] url::ParseError),
99 #[error(transparent)]
100 Request(#[from] reqwest::Error),
101}
102
103#[derive(Error, Debug)]
104#[error(transparent)]
105pub enum UploadError {
106 #[error("error parsing upload URL: {0}")]
107 ParseError(#[from] url::ParseError),
108 Lua(#[from] mlua::Error),
109 Request(#[from] reqwest::Error),
110 #[error("server {0} responded with error status: {1}")]
111 Server(Url, StatusCode),
112 #[error("client error when requesting {0}\nStatus code: {1}")]
113 Client(Url, StatusCode),
114 RockCheck(#[from] RockCheckError),
115 #[error("rock already exists on server: {0}")]
116 RockExists(Url),
117 #[error("unable to read rockspec: {0}")]
118 RockspecRead(#[from] std::io::Error),
119 #[cfg(not(target_env = "msvc"))]
120 #[error("{0}.\nHINT: If you'd like to skip the signing step supply `--sign-protocol none` to the CLI")]
121 Signature(#[from] gpgme::Error),
122 ToolCheck(#[from] ToolCheckError),
123 UserCheck(#[from] UserCheckError),
124 ApiKeyUnspecified(#[from] ApiKeyUnspecified),
125 ValidationError(#[from] RemoteProjectTomlValidationError),
126 #[error(
127 "unsupported version: `{0}`.\nLux can upload packages with a SemVer version, 'dev' or 'scm'"
128 )]
129 UnsupportedVersion(String),
130 #[error("{0}")] Rockspec(String),
132}
133
134pub struct ApiKey(String);
135
136#[derive(Error, Debug)]
137#[error("no API key provided! Please set the $LUX_API_KEY variable")]
138pub struct ApiKeyUnspecified;
139
140impl ApiKey {
141 pub fn new() -> Result<Self, ApiKeyUnspecified> {
144 Ok(Self(
145 env::var("LUX_API_KEY").map_err(|_| ApiKeyUnspecified)?,
146 ))
147 }
148
149 pub unsafe fn from(str: String) -> Self {
159 Self(str)
160 }
161
162 pub unsafe fn get(&self) -> &String {
169 &self.0
170 }
171}
172
173#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
174#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
175#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
176#[serde(rename_all = "lowercase")]
177pub enum SignatureProtocol {
178 None,
179 #[cfg(not(target_env = "msvc"))]
180 Assuan,
181 #[cfg(not(target_env = "msvc"))]
182 CMS,
183 #[cfg(not(target_env = "msvc"))]
184 Default,
185 #[cfg(not(target_env = "msvc"))]
186 G13,
187 #[cfg(not(target_env = "msvc"))]
188 GPGConf,
189 #[cfg(not(target_env = "msvc"))]
190 OpenPGP,
191 #[cfg(not(target_env = "msvc"))]
192 Spawn,
193 #[cfg(not(target_env = "msvc"))]
194 UIServer,
195}
196
197#[cfg(not(target_env = "msvc"))]
198impl Default for SignatureProtocol {
199 fn default() -> Self {
200 Self::Default
201 }
202}
203
204#[cfg(target_env = "msvc")]
205impl Default for SignatureProtocol {
206 fn default() -> Self {
207 Self::None
208 }
209}
210
211#[cfg(not(target_env = "msvc"))]
212impl From<SignatureProtocol> for gpgme::Protocol {
213 fn from(val: SignatureProtocol) -> Self {
214 match val {
215 SignatureProtocol::Default => gpgme::Protocol::Default,
216 SignatureProtocol::OpenPGP => gpgme::Protocol::OpenPgp,
217 SignatureProtocol::CMS => gpgme::Protocol::Cms,
218 SignatureProtocol::GPGConf => gpgme::Protocol::GpgConf,
219 SignatureProtocol::Assuan => gpgme::Protocol::Assuan,
220 SignatureProtocol::G13 => gpgme::Protocol::G13,
221 SignatureProtocol::UIServer => gpgme::Protocol::UiServer,
222 SignatureProtocol::Spawn => gpgme::Protocol::Spawn,
223 SignatureProtocol::None => unreachable!(),
224 }
225 }
226}
227
228async fn upload_from_project(
229 project: &Project,
230 api_key: &ApiKey,
231 #[cfg(target_env = "msvc")] _protocol: SignatureProtocol,
232 #[cfg(not(target_env = "msvc"))] protocol: SignatureProtocol,
233 config: &Config,
234) -> Result<(), UploadError> {
235 let client = Client::builder().https_only(true).build()?;
236
237 let rockspec = project.toml().into_remote()?;
238
239 if let PackageVersion::StringVer(ver) = rockspec.version() {
240 return Err(UploadError::UnsupportedVersion(ver.to_string()));
241 }
242
243 helpers::ensure_tool_version(&client, config.server()).await?;
244 helpers::ensure_user_exists(&client, api_key, config.server()).await?;
245
246 if helpers::rock_exists(
247 &client,
248 api_key,
249 rockspec.package(),
250 rockspec.version(),
251 config.server(),
252 )
253 .await?
254 {
255 return Err(UploadError::RockExists(config.server().clone()));
256 }
257
258 let rockspec_content = rockspec
259 .to_lua_remote_rockspec_string()
260 .map_err(|err| UploadError::Rockspec(err.to_string()))?;
261
262 #[cfg(target_env = "msvc")]
263 let signed: Option<String> = None;
264
265 #[cfg(not(target_env = "msvc"))]
266 let signed = if let SignatureProtocol::None = protocol {
267 None
268 } else {
269 let mut ctx = Context::from_protocol(protocol.into())?;
270 let mut signature = Data::new()?;
271
272 ctx.set_armor(true);
273 ctx.sign_detached(rockspec_content.clone(), &mut signature)?;
274
275 let mut signature_str = String::new();
276 signature.read_to_string(&mut signature_str)?;
277
278 Some(signature_str)
279 };
280
281 let rockspec = Part::text(rockspec_content)
282 .file_name(format!(
283 "{}-{}.rockspec",
284 rockspec.package(),
285 rockspec.version()
286 ))
287 .mime_str("application/octet-stream")?;
288
289 let multipart = {
290 let multipart = Form::new().part("rockspec_file", rockspec);
291
292 match signed {
293 Some(signature) => {
294 let part = Part::text(signature).file_name("project.rockspec.sig");
295 multipart.part("rockspec_sig", part)
296 }
297 None => multipart,
298 }
299 };
300
301 let response = client
302 .post(unsafe { helpers::url_for_method(config.server(), api_key, "upload")? })
303 .multipart(multipart)
304 .send()
305 .await?;
306
307 let status = response.status();
308 if status.is_client_error() {
309 Err(UploadError::Client(config.server().clone(), status))
310 } else if status.is_server_error() {
311 Err(UploadError::Server(config.server().clone(), status))
312 } else {
313 Ok(())
314 }
315}
316
317mod helpers {
318 use super::*;
319 use crate::package::{PackageName, PackageVersion};
320 use crate::upload::RockCheckError;
321 use crate::upload::{ToolCheckError, UserCheckError};
322 use reqwest::Client;
323 use url::Url;
324
325 pub(crate) unsafe fn url_for_method(
330 server_url: &Url,
331 api_key: &ApiKey,
332 endpoint: &str,
333 ) -> Result<Url, url::ParseError> {
334 server_url
335 .join("api/1/")
336 .expect("error constructing 'api/1/' path")
337 .join(&format!("{}/", api_key.get()))?
338 .join(endpoint)
339 }
340
341 pub(crate) async fn ensure_tool_version(
342 client: &Client,
343 server_url: &Url,
344 ) -> Result<(), ToolCheckError> {
345 let url = server_url.join("api/tool_version")?;
346 let response: VersionCheckResponse = client
347 .post(url)
348 .json(&("current", TOOL_VERSION))
349 .send()
350 .await?
351 .json()
352 .await?;
353
354 if response.version == TOOL_VERSION {
355 Ok(())
356 } else {
357 Err(ToolCheckError::ToolOutdated(
358 server_url.to_string(),
359 response,
360 ))
361 }
362 }
363
364 pub(crate) async fn ensure_user_exists(
365 client: &Client,
366 api_key: &ApiKey,
367 server_url: &Url,
368 ) -> Result<(), UserCheckError> {
369 let response = client
370 .get(unsafe { url_for_method(server_url, api_key, "status")? })
371 .send()
372 .await?;
373 let status = response.status();
374 if status.is_client_error() {
375 Err(UserCheckError::UserNotFound)
376 } else if status.is_server_error() {
377 Err(UserCheckError::Server(server_url.clone(), status))
378 } else {
379 Ok(())
380 }
381 }
382
383 pub(crate) async fn rock_exists(
384 client: &Client,
385 api_key: &ApiKey,
386 name: &PackageName,
387 version: &PackageVersion,
388 server: &Url,
389 ) -> Result<bool, RockCheckError> {
390 Ok(client
391 .get(unsafe { url_for_method(server, api_key, "check_rockspec")? })
392 .query(&(
393 ("package", name.to_string()),
394 ("version", version.to_string()),
395 ))
396 .send()
397 .await?
398 .text()
399 .await?
400 != "{}")
401 }
402}