use super::*;
use crate::widget::{self, gallery};
use axum::body::Bytes;
#[derive(Deserialize, Default)]
#[serde(default)]
pub(crate) struct AddBody {
name: String,
file: String,
job: String,
caption: String,
model: String,
prompt: String,
cost: Option<f64>,
poster: String,
failed: String,
lines: Option<u8>,
desk: Option<i64>,
writer: String,
#[serde(rename = "for")]
lasts: String,
}
fn read_add(body: &Bytes) -> Option<AddBody> {
let mut v: serde_json::Value = serde_json::from_slice(body).ok()?;
v.as_object_mut()?.retain(|_, x| !x.is_null());
serde_json::from_value(v).ok()
}
fn refused(code: StatusCode, error: impl Into<String>) -> Response {
(code, Json(json!({ "error": error.into() }))).into_response()
}
pub(crate) async fn pane_add_gallery(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
body: Bytes,
) -> Response {
let placed = match agent_pane(&app, &headers, &id) {
Ok(p) => p,
Err(no) => return *no,
};
let Some(b) = read_add(&body) else {
return refused(StatusCode::BAD_REQUEST, "not an output");
};
let writer = format!("panel {}", placed.pane.slot);
add(&app, placed.desk_id, &id, &writer, b).await
}
pub(crate) async fn add_gallery(State(app): S, headers: HeaderMap, body: Bytes) -> Response {
if !authorized(&app, &headers) {
return StatusCode::UNAUTHORIZED.into_response();
}
let Some(b) = read_add(&body) else {
return refused(StatusCode::BAD_REQUEST, "not an output");
};
let Some(desk) = b.desk.filter(|d| *d > 0) else {
return refused(
StatusCode::BAD_REQUEST,
"a gallery is on a desk: say which, by its id",
);
};
if !app
.store
.desks()
.map(|ds| ds.iter().any(|d| d.id == desk))
.unwrap_or(false)
{
return refused(StatusCode::NOT_FOUND, format!("there is no desk {desk}"));
}
let writer = if b.writer.trim().is_empty() {
"a script".to_string()
} else {
gallery::clip(&b.writer, 40)
};
add(&app, desk, "", &writer, b).await
}
async fn add(app: &Arc<App>, desk: i64, pane: &str, writer: &str, b: AddBody) -> Response {
let name = if b.name.trim().is_empty() {
"gallery".to_string()
} else {
b.name.trim().to_string()
};
if !widget::name_ok(&name) {
return refused(
StatusCode::BAD_REQUEST,
"a gallery's name is lowercase letters, digits and dashes, at most 32",
);
}
let job = gallery::clip(&b.job, gallery::JOB_CHARS);
if b.file.trim().is_empty() && job.is_empty() {
return refused(
StatusCode::BAD_REQUEST,
"an output needs its file, or a job to show it is being made",
);
}
if b.file.contains("://") || b.poster.contains("://") {
return refused(
StatusCode::BAD_REQUEST,
"a gallery takes a file on this machine, not a URL: download the output first",
);
}
let cost = match b.cost {
None => None,
Some(c) => match gallery::micros(c) {
Some(m) => Some(m),
None => return refused(StatusCode::BAD_REQUEST, "cost is dollars, 0 or more"),
},
};
if let Some(no) = said_no(app, desk, pane, &name) {
return no;
}
let (stored, poster) = match copy_files(app, &b.file, &b.poster).await {
Ok(x) => x,
Err(no) => return *no,
};
let a = gallery::Add {
job,
stored,
source: b.file.trim().to_string(),
caption: gallery::clip(&b.caption, gallery::CAPTION_CHARS),
model: gallery::clip(&b.model, gallery::MODEL_CHARS),
prompt: gallery::clip(&b.prompt, gallery::PROMPT_CHARS),
cost_micros: cost,
poster,
failed: gallery::clip(&b.failed, gallery::FAILED_CHARS),
pane: pane.to_string(),
writer: writer.to_string(),
};
let id = match app
.store
.clocked(|c, now| gallery::add(c, desk, &name, &a, now))
{
Ok(id) => id,
Err(e) => return err(e),
};
let lines = b
.lines
.unwrap_or(gallery::LINES_DEFAULT)
.clamp(widget::LINES_MIN, widget::LINES_MAX);
let said = [a.caption.as_str(), a.model.as_str()]
.iter()
.filter(|s| !s.is_empty())
.copied()
.collect::<Vec<_>>()
.join(" · ");
let raw = gallery::raw(lines, if said.is_empty() { &a.job } else { &said });
let w = widget::Writer {
source: widget::Source::Push,
writer,
pane,
title: "",
owner: 0,
};
let allowed = pane.is_empty()
|| matches!(
app.store.clocked(|c, now| {
widget::ask_grandfather(c, desk, &name, now)?;
widget::ask_of(c, desk, &name)
}),
Ok(Some(ref a)) if a.answer == "yes"
);
let r = if allowed {
super::api_widget::push(app, desk, &name, &raw, &w)
} else {
let pb = super::api_widget::PushBody {
name: name.clone(),
body: serde_json::Value::String(raw.clone()),
lasts: b.lasts.clone(),
..Default::default()
};
super::api_widget::ask_for_box(app, desk, pane, writer, &pb, &raw)
};
let status = r.status();
if !status.is_success() {
return r;
}
let waiting = status == StatusCode::ACCEPTED;
let file = a
.stored
.as_ref()
.map(|s| s.file.clone())
.unwrap_or_default();
(
status,
Json(json!({ "ok": true, "id": id, "desk": desk, "name": name, "file": file, "waiting": waiting })),
)
.into_response()
}
async fn copy_files(
app: &Arc<App>,
file: &str,
poster: &str,
) -> Result<(Option<gallery::Stored>, Option<gallery::Stored>), Box<Response>> {
let dir = app.paths.data_dir.join(gallery::DIR);
let (file, poster) = (file.trim().to_string(), poster.trim().to_string());
if !poster.is_empty() && gallery::kind_of(&crate::render::ext_of(&poster)) != Some("image") {
return Err(Box::new(refused(
StatusCode::BAD_REQUEST,
"the poster is a picture, a clip's first frame",
)));
}
let copied = tokio::task::spawn_blocking(move || -> Result<_, String> {
let stored = (!file.is_empty())
.then(|| gallery::copy_in(&dir, std::path::Path::new(&file)))
.transpose()
.map_err(|e| e.to_string())?;
let poster = (!poster.is_empty())
.then(|| gallery::copy_in(&dir, std::path::Path::new(&poster)))
.transpose()
.map_err(|e| format!("the poster: {e}"))?;
Ok((stored, poster))
})
.await;
match copied {
Ok(Ok(x)) => Ok(x),
Ok(Err(why)) => Err(Box::new(refused(StatusCode::BAD_REQUEST, why))),
Err(e) => Err(Box::new(err(anyhow::anyhow!(e)))),
}
}
fn said_no(app: &Arc<App>, desk: i64, pane: &str, name: &str) -> Option<Response> {
let a = match app.store.clocked(|c, _| widget::ask_of(c, desk, name)) {
Ok(Some(a)) => a,
Ok(None) => return None,
Err(e) => return Some(err(e)),
};
let no = a.answer == "no" || (a.answer == "removed" && (pane.is_empty() || a.pane == pane));
let which = if name == "gallery" {
"the gallery".to_string()
} else {
format!("the gallery {name}")
};
no.then(|| {
refused(
StatusCode::CONFLICT,
format!(
"the user {} {which} on this desk: leave it, or say in your reply why it would help",
if a.answer == "no" { "said not now to" } else { "removed" }
),
)
})
}
pub(crate) async fn list_gallery(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let desk: i64 = q.get("desk").and_then(|d| d.parse().ok()).unwrap_or(0);
let name = q.get("name").map(String::as_str).unwrap_or("gallery");
let all = q.get("all").is_some_and(|a| a == "1");
let r = app.store.clocked(|c, _| {
let items = gallery::items(c, desk, name, all, gallery::LIST_MAX)?;
Ok(items)
});
let desks: serde_json::Map<String, serde_json::Value> = app
.store
.desks()
.unwrap_or_default()
.into_iter()
.map(|d| (d.id.to_string(), json!(d.name)))
.collect();
match r {
Ok(items) => Json(json!({ "items": items, "desks": desks, "all": all })).into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub(crate) struct PickBody {
item: i64,
line: String,
}
pub(crate) async fn pick_gallery(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
body: Bytes,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let b: PickBody = serde_json::from_slice(&body).unwrap_or_default();
match app
.store
.clocked(|c, now| gallery::pick(c, b.item, &b.line, now))
{
Ok(Some(desk)) => {
super::api_thread::threads_moved(&app, desk);
Json(json!({ "ok": true })).into_response()
}
Ok(None) => refused(StatusCode::NOT_FOUND, "that output is not in a gallery"),
Err(e) => err(e),
}
}
pub(crate) async fn gallery_file(
State(app): S,
Path(file): Path<String>,
req: HeaderMap,
) -> Response {
if !gallery::file_name_ok(&file) {
return StatusCode::NOT_FOUND.into_response();
}
let ext = crate::render::ext_of(&file);
let mut headers = HeaderMap::new();
if let Ok(v) =
HeaderValue::from_str(mime_guess::from_ext(&ext).first_or_octet_stream().as_ref())
{
headers.insert(header::CONTENT_TYPE, v);
}
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, max-age=31536000, immutable"),
);
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
let at = app.paths.data_dir.join(gallery::DIR).join(&file);
serve_file(&at, headers, &req).await
}