sys/sysmod/http/
priv_camera.rs

1use super::{WebResult, error_resp};
2use crate::sysmod::camera::{PicEntry, TakePicOption, create_thumbnail, take_a_pic};
3use crate::sysmod::twitter::LIMIT_PHOTO_COUNT;
4use crate::sysmod::{camera::resize, http::error_resp_msg, twitter::LIMIT_PHOTO_SIZE};
5use crate::taskserver::Control;
6use actix_web::{HttpResponse, Responder, http::header::ContentType, web};
7use anyhow::{Result, anyhow, bail};
8use log::error;
9use serde::Deserialize;
10use serenity::http::StatusCode;
11use std::{cmp, collections::BTreeMap};
12use tokio::{fs::File, io::AsyncReadExt};
13
14/// GET /priv/camera/ Camera インデックスページ。
15#[actix_web::get("/camera/")]
16async fn index_get() -> impl Responder {
17    let body = r#"<!DOCTYPE html>
18<html lang="en">
19  <head>
20    <title>(Privileged) Camera</title>
21  </head>
22  <body>
23    <h1>(Privileged) Camera</h1>
24    <form action="./take" method="post">
25      <input type="submit" value="Take a picture!">
26    </form>
27    <p><a href="./history">Picture List</a></p>
28    <p><a href="./archive">Archive</a></p>
29    <h2>Navigation</h2>
30    <p><a href="../">Main Page</a></p>
31  </body>
32</html>
33"#;
34
35    HttpResponse::Ok()
36        .content_type(ContentType::html())
37        .body(body)
38}
39
40#[derive(Deserialize)]
41struct HistArGetQuery {
42    #[serde(default)]
43    page: usize,
44}
45
46/// GET /priv/camera/history/ 写真一覧。
47#[actix_web::get("/camera/history")]
48async fn history_get(ctrl: web::Data<Control>, query: web::Query<HistArGetQuery>) -> HttpResponse {
49    let html = {
50        let camera = ctrl.sysmods().camera.lock().await;
51        let page_by = camera.config.page_by as usize;
52        let (hist, _) = camera.pic_list();
53
54        create_pic_list_page(
55            hist,
56            "history",
57            query.page,
58            page_by,
59            "(Privileged) Picture History",
60            &[("archive", "Archive"), ("delete", "Delete")],
61        )
62    };
63
64    HttpResponse::Ok()
65        .content_type(ContentType::html())
66        .body(html)
67}
68
69/// GET /priv/camera/archive/ アーカイブ済み写真一覧。
70#[actix_web::get("/camera/archive")]
71async fn archive_get(ctrl: web::Data<Control>, query: web::Query<HistArGetQuery>) -> HttpResponse {
72    let html = {
73        let camera = ctrl.sysmods().camera.lock().await;
74        let page_by = camera.config.page_by as usize;
75        let (_, archive) = camera.pic_list();
76
77        create_pic_list_page(
78            archive,
79            "archive",
80            query.page,
81            page_by,
82            "(Privileged) Picture Archive",
83            &[
84                ("twitter", "Post on Twitter (Max 4 pics or less)"),
85                ("delete", "Delete"),
86            ],
87        )
88    };
89
90    HttpResponse::Ok()
91        .content_type(ContentType::html())
92        .body(html)
93}
94
95/// history/archive 共用写真リスト HTML 生成。
96///
97/// * `pic_list` - 画像データ。
98/// * `start` - pic_list の何番目から表示するか。
99/// * `page_by` - start からいくつ画像を表示するか。
100/// * `title` - タイトル。
101/// * `commands` - POST の "cmd" パラメータで送られる値とラジオボタンに
102///   添えるラベルからなるタプルの配列
103fn create_pic_list_page(
104    pic_list: &BTreeMap<String, PicEntry>,
105    img_path_dir: &str,
106    start: usize,
107    page_by: usize,
108    title: &str,
109    commands: &[(&str, &str)],
110) -> String {
111    let total = pic_list.len();
112    let data: Vec<_> = pic_list.keys().rev().skip(start).take(page_by).collect();
113
114    let mut fig_list = String::new();
115    fig_list += r#"    <form method="post">
116      <fieldset>
117        <legend>Commands for selected items</legend>
118        <input type="submit" value="Execute">
119"#;
120    let mut is_first = true;
121    for &(cmd, label) in commands {
122        let checked = if is_first {
123            is_first = false;
124            r#" checked="checked""#
125        } else {
126            ""
127        };
128        fig_list += &format!(
129            r#"        <label><input type="radio" name="cmd" value="{cmd}"{checked}>{label}</label>
130"#
131        );
132    }
133    fig_list += r#"      </fieldset>
134      <p><input type="reset" value="Reset"></p>
135
136"#;
137
138    for name in data {
139        fig_list += &format!(
140            r#"      <input type="checkbox" id="{name}" name="target" value="{name}">
141      <figure class="pic">
142        <a href="./pic/{img_path_dir}/{name}/main"><img src="./pic/{img_path_dir}/{name}/thumb" alt="{name}"></a>
143        <figcaption class="pic"><label for="{name}">{name}</label></figcaption>
144      </figure>
145"#
146        );
147    }
148    fig_list += "    </form>";
149
150    let mut page_navi = String::new();
151    page_navi += &format!("<p>{} files</p>", pic_list.len());
152    page_navi += "<p>";
153    for left in (0..total).step_by(page_by) {
154        let right = cmp::min(left + page_by - 1, total - 1);
155        let link = if (left..=right).contains(&start) {
156            format!(r#"{}-{} "#, left + 1, right + 1)
157        } else {
158            format!(
159                r#"<a href="?page={}">{}-{}</a> "#,
160                left,
161                left + 1,
162                right + 1
163            )
164        };
165        page_navi += &link;
166    }
167    page_navi += "</p>";
168
169    format!(
170        r#"<!DOCTYPE html>
171<html lang="en">
172  <head>
173    <title>{title}</title>
174    <style>
175      figure.pic {{
176        display: inline-block;
177        margin: 5px;
178      }}
179      figcaption.pic {{
180        font-size: 100%;
181        text-align: center;
182      }}
183    </style>
184  </head>
185  <body>
186    <h1>{title}</h1>
187      {page_navi}
188
189{fig_list}
190
191    <h2>Navigation</h2>
192    <p><a href="./">Camera Main Page</a></p>
193  </body>
194</html>
195"#
196    )
197}
198
199/// 同一キーはデシリアライズ対応していないので自力でパースする。
200///
201/// cmd=c&target=t1&target=t2&...
202fn parse_cmd_targets(
203    iter: impl IntoIterator<Item = (String, String)>,
204) -> Result<(String, Vec<String>)> {
205    let mut cmd = None;
206    let mut targets = Vec::new();
207
208    for (key, value) in iter {
209        match key.as_str() {
210            "cmd" => {
211                cmd = Some(value);
212            }
213            "target" => {
214                targets.push(value);
215            }
216            _ => {}
217        }
218    }
219
220    cmd.map_or_else(|| Err(anyhow!("cmd is required")), |cmd| Ok((cmd, targets)))
221}
222
223/// POST /priv/camera/history
224///
225/// * `cmd` - "archive" or "delete"
226/// * `target` - 対象の picture ID (複数回指定可)
227#[actix_web::post("/camera/history")]
228async fn history_post(
229    ctrl: web::Data<Control>,
230    form: web::Form<Vec<(String, String)>>,
231) -> HttpResponse {
232    let param = parse_cmd_targets(form.0);
233    if let Err(why) = param {
234        return HttpResponse::BadRequest()
235            .content_type(ContentType::plaintext())
236            .body(why.to_string());
237    }
238    let (cmd, targets) = param.unwrap();
239
240    match cmd.as_str() {
241        "archive" => {
242            if let Err(why) = archive_pics(&ctrl, &targets).await {
243                if why.is::<std::io::Error>() {
244                    HttpResponse::InternalServerError()
245                        .content_type(ContentType::plaintext())
246                        .body(why.to_string())
247                } else {
248                    HttpResponse::BadRequest()
249                        .content_type(ContentType::plaintext())
250                        .body(why.to_string())
251                }
252            } else {
253                // 成功したら archive GET へリダイレクト
254                HttpResponse::SeeOther()
255                    .append_header(("LOCATION", "./archive"))
256                    .finish()
257            }
258        }
259        "delete" => {
260            if let Err(why) = delete_history_pics(&ctrl, &targets).await {
261                HttpResponse::BadRequest()
262                    .content_type(ContentType::plaintext())
263                    .body(why.to_string())
264            } else {
265                // 成功したら history GET へリダイレクト
266                HttpResponse::SeeOther()
267                    .append_header(("LOCATION", "./history"))
268                    .finish()
269            }
270        }
271        _ => HttpResponse::BadRequest()
272            .content_type(ContentType::plaintext())
273            .body("invalid command"),
274    }
275}
276
277/// POST /priv/camera/archive
278///
279/// * `cmd` - "twitter" or "delete"
280/// * `target` - 対象の picture ID (複数回指定可)
281#[actix_web::post("/camera/archive")]
282async fn archive_post(
283    ctrl: web::Data<Control>,
284    form: web::Form<Vec<(String, String)>>,
285) -> HttpResponse {
286    let param = parse_cmd_targets(form.0);
287    if let Err(why) = param {
288        return HttpResponse::BadRequest()
289            .content_type(ContentType::plaintext())
290            .body(why.to_string());
291    }
292    let (cmd, targets) = param.unwrap();
293
294    match cmd.as_str() {
295        "twitter" => {
296            if targets.is_empty() || targets.len() > LIMIT_PHOTO_COUNT {
297                error_resp_msg(StatusCode::BAD_REQUEST, "invalid pic count")
298            } else if let Err(why) = twitter_post(&ctrl, &targets).await {
299                error_resp_msg(StatusCode::INTERNAL_SERVER_ERROR, &why.to_string())
300            } else {
301                // 成功したら archive GET へリダイレクト
302                HttpResponse::SeeOther()
303                    .append_header(("LOCATION", "./history"))
304                    .finish()
305            }
306        }
307        "delete" => {
308            if let Err(why) = delete_archive_pics(&ctrl, &targets).await {
309                error_resp_msg(StatusCode::BAD_REQUEST, &why.to_string())
310            } else {
311                // 成功したら archive GET へリダイレクト
312                HttpResponse::SeeOther()
313                    .append_header(("LOCATION", "./history"))
314                    .finish()
315            }
316        }
317        _ => HttpResponse::BadRequest()
318            .content_type(ContentType::plaintext())
319            .body("invalid command"),
320    }
321}
322
323async fn archive_pics(ctrl: &Control, ids: &[String]) -> Result<()> {
324    let mut camera = ctrl.sysmods().camera.lock().await;
325    for id in ids {
326        camera.push_pic_archive(id).await?;
327    }
328
329    Ok(())
330}
331
332async fn delete_history_pics(ctrl: &Control, ids: &[String]) -> Result<()> {
333    let mut camera = ctrl.sysmods().camera.lock().await;
334    for id in ids {
335        camera.delete_pic_history(id).await?;
336    }
337
338    Ok(())
339}
340
341async fn delete_archive_pics(ctrl: &Control, ids: &[String]) -> Result<()> {
342    let mut camera = ctrl.sysmods().camera.lock().await;
343    for id in ids {
344        camera.delete_pic_archive(id).await?;
345    }
346
347    Ok(())
348}
349
350async fn twitter_post(ctrl: &Control, ids: &[String]) -> Result<()> {
351    assert!(ids.len() <= LIMIT_PHOTO_COUNT);
352
353    let mut binlist = Vec::new();
354    {
355        let camera = ctrl.sysmods().camera.lock().await;
356
357        let (_, archive) = camera.pic_list();
358        for id in ids {
359            if let Some(entry) = archive.get(id) {
360                let mut file = File::open(&entry.path_main).await?;
361                let mut bin = Vec::new();
362                let _ = file.read_to_end(&mut bin).await?;
363                binlist.push(bin);
364            } else {
365                bail!("ID not found");
366            }
367        }
368    }
369    let mut resized_list = Vec::new();
370    for bin in binlist {
371        let mut w = 1280_u32;
372        let mut h = 720_u32;
373        let mut resized = bin;
374        while resized.len() > LIMIT_PHOTO_SIZE {
375            resized = resize(&resized, w, h)?;
376            w /= 2;
377            h /= 2;
378        }
379        resized_list.push(resized);
380    }
381    {
382        let mut tw = ctrl.sysmods().twitter.lock().await;
383
384        let mut midlist: Vec<u64> = Vec::new();
385        for bin in resized_list {
386            let media_id = tw.media_upload(bin).await?;
387            midlist.push(media_id);
388        }
389        let text = ids.join("\n");
390        tw.tweet_custom(&text, None, &midlist).await?;
391    }
392
393    Ok(())
394}
395
396/// GET /priv/camera/pic/history/{name}/{kind}
397/// 写真取得エンドポイント。
398///
399/// image/jpeg を返す。
400#[actix_web::get("/camera/pic/history/{name}/{kind}")]
401async fn pic_history_get(ctrl: web::Data<Control>, path: web::Path<(String, String)>) -> WebResult {
402    let (name, kind) = path.into_inner();
403
404    pic_get_internal(&ctrl, StorageType::History, &name, &kind).await
405}
406
407/// GET /priv/camera/pic/history/{name}/{kind}
408/// 写真取得エンドポイント。
409///
410/// image/jpeg を返す。
411#[actix_web::get("/camera/pic/archive/{name}/{kind}")]
412async fn pic_archive_get(ctrl: web::Data<Control>, path: web::Path<(String, String)>) -> WebResult {
413    let (name, kind) = path.into_inner();
414
415    pic_get_internal(&ctrl, StorageType::Archive, &name, &kind).await
416}
417
418enum StorageType {
419    History,
420    Archive,
421}
422
423async fn pic_get_internal(ctrl: &Control, stype: StorageType, name: &str, kind: &str) -> WebResult {
424    let is_th = match kind {
425        "main" => false,
426        "thumb" => true,
427        _ => return Ok(error_resp(StatusCode::BAD_REQUEST)),
428    };
429
430    let value = {
431        let camera = ctrl.sysmods().camera.lock().await;
432        let (hist, ar) = camera.pic_list();
433        let dict = match stype {
434            StorageType::History => hist,
435            StorageType::Archive => ar,
436        };
437        dict.get(name).cloned()
438    };
439
440    if let Some(entry) = value {
441        let path = match is_th {
442            false => entry.path_main,
443            true => entry.path_th,
444        };
445        let mut file = File::open(path).await.map_err(|e| anyhow!(e))?;
446        let mut bin = Vec::new();
447        let _ = file.read_to_end(&mut bin).await.map_err(|e| anyhow!(e))?;
448
449        let resp = HttpResponse::Ok()
450            .content_type(ContentType::jpeg())
451            .body(bin);
452        Ok(resp)
453    } else {
454        Ok(error_resp(StatusCode::NOT_FOUND))
455    }
456}
457
458#[actix_web::post("/camera/take")]
459async fn take_post(ctrl: web::Data<Control>) -> WebResult {
460    let pic = take_a_pic(TakePicOption::new()).await;
461    if let Err(ref e) = pic {
462        error!("take a picture error");
463        error!("{e:#}");
464    }
465    let pic = pic?;
466
467    /*
468    // twitter upload test
469    {
470        let mini = create_thumbnail(&pic)?;
471        let tw = ctrl.sysmods().twitter.lock().await;
472        let id = tw.media_upload(mini).await;
473        info!("{id:?}");
474        id?;
475    }
476     */
477
478    let thumb = create_thumbnail(&pic)?;
479
480    let mut camera = ctrl.sysmods().camera.lock().await;
481    camera.push_pic_history(&pic, &thumb).await?;
482    drop(camera);
483
484    let resp = HttpResponse::Ok()
485        .content_type(ContentType::jpeg())
486        .body(pic);
487    Ok(resp)
488}