cookcli 0.38.1

A command-line interface for managing and working with Cooklang recipes
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
// This file includes a substantial portion of code from
// https://github.com/Zheoni/cooklang-chef
//
// The original code is licensed under the MIT License, a copy of which
// is provided below in addition to our project's license.
//
//

// MIT License

// Copyright (c) 2023 Francisco J. Sanchez

// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:

// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.

// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

use crate::util::resolve_to_absolute_path;
use crate::Context;
use anyhow::{bail, Context as _, Result};
use axum::{
    body::Body,
    extract::{DefaultBodyLimit, Path},
    http::{header, HeaderValue, Response, StatusCode},
    routing::{get, post},
    Router,
};
use camino::{Utf8Path, Utf8PathBuf};
use clap::{Args, Subcommand};
#[cfg(feature = "sync")]
use std::sync::Mutex;
use std::{net::IpAddr, net::SocketAddr, sync::Arc};
use tower_http::{services::ServeDir, set_header::SetResponseHeader};
use tracing::{error, info};

mod activity;
mod aisle_file;
pub mod auth;
mod cors;
mod fs_atomic;
mod handlers;
mod lsp_bridge;
mod plan_text;
mod rename;
mod shopping_list_watcher;
mod title_image;
mod ui;

#[derive(Debug, Args)]
#[command(args_conflicts_with_subcommands = true)]
pub struct ServerArgs {
    #[command(subcommand)]
    command: Option<ServerCommand>,

    /// Root directory containing your recipe files
    ///
    /// The server will recursively scan this directory for .cook files
    /// and make them available through the web interface. Defaults to
    /// the current directory if not specified.
    #[arg(value_hint = clap::ValueHint::DirPath)]
    base_path: Option<Utf8PathBuf>,

    /// Allow connections from external hosts (not just localhost)
    ///
    /// By default, the server only accepts connections from localhost
    /// for security. Use this flag to allow access from other devices
    /// on your network. If an IP address is provided the server will
    /// only listen on that address. Be cautious when using this flag
    /// on public networks. A device that opens the web UI by host name
    /// rather than IP address (e.g. http://nas.local:9080) also needs that
    /// origin passed to --cors-origin, or the server refuses to answer it.
    #[arg(long, num_args = 0..=1, value_name = "ADDRESS")]
    host: Option<Option<IpAddr>>,

    /// Port number for the HTTP server
    ///
    /// The server will listen on this port. Make sure the port is not
    /// already in use by another application.
    #[arg(short = 'p', long, default_value_t = 9080)]
    port: u16,

    /// URL prefix for running behind a reverse proxy at a subpath
    ///
    /// When set, all routes will be served under this prefix. For example,
    /// --url-prefix "/cook" will make the server available at /cook/ instead
    /// of /. This is useful when running behind a reverse proxy like nginx
    /// that maps a subpath to this server. The prefix must start with "/".
    #[arg(long)]
    url_prefix: Option<String>,

    /// Automatically open the web interface in your default browser
    ///
    /// When enabled, the server will launch your default web browser
    /// and navigate to the server URL after startup.
    // #[cfg(feature = "ui")]
    #[arg(long, default_value_t = false)]
    open: bool,

    /// Origin allowed to make cross-origin browser requests (repeatable)
    ///
    /// Pass once per origin, e.g. --cors-origin http://localhost:3000. By
    /// default no other origin may read from the server. Use "*" to let any
    /// origin read; under it the server still refuses cross-origin writes
    /// with 403. Naming explicit origins lets those origins read and write.
    /// "*" cannot be combined with explicit origins. The host of a named
    /// origin is also one the server answers at: any host name other than
    /// localhost has to be named this way, even to open the web UI there.
    /// Requests with no Origin header -- curl and other non-browser clients --
    /// are affected only by that last rule. Origins may
    /// also be named in COOK_CORS_ORIGIN, separated by commas, which this flag
    /// overrides -- for containers, where a flag means restating the whole
    /// command.
    #[arg(long, value_name = "ORIGIN")]
    cors_origin: Vec<String>,

    /// Allow cross-origin requests to carry cookies and credentials
    ///
    /// Requires at least one explicit --cors-origin; browsers reject
    /// credentialed requests against a wildcard origin.
    #[arg(long, default_value_t = false)]
    cors_allow_credentials: bool,

    /// Disable same-origin enforcement: the Host check, writes and the editor's
    /// language server
    ///
    /// By default the server answers only requests sent to localhost, an IP
    /// address, or the host of a --cors-origin. Any other host name has to be
    /// named, or a site could point a domain of its own at the server (DNS
    /// rebinding) and read it as its own. A browser request that would modify
    /// recipes is rejected unless its Origin is likewise the server's own
    /// address or is named by --cors-origin, and the same rule decides which
    /// browsers may open the recipe editor's language server. This has nothing
    /// to do with the cross-origin read policy the other --cors-* flags
    /// configure. Use it only when a reverse proxy rewrites Host in a way that
    /// cannot be expressed with --cors-origin. The former spelling --no-cors
    /// still works.
    #[arg(long = "no-csrf-check", alias = "no-cors", action = clap::ArgAction::SetFalse)]
    csrf_check: bool,

    /// Maximum number of language server sessions to run at once
    ///
    /// The built-in editor opens one websocket per edit tab, and each one runs
    /// a `cook lsp` subprocess for as long as that tab stays open. Once this
    /// many are running, further connections are refused with 503 until one
    /// closes; the editor reconnects on its own, so a refused tab recovers
    /// when a slot frees up. Raise it if you keep many tabs open. Set it to 0
    /// to turn the bridge off entirely, which is worth doing when serving
    /// --host on a network you do not control.
    #[arg(long, value_name = "N", default_value_t = lsp_bridge::DEFAULT_MAX_SESSIONS)]
    max_lsp_sessions: u16,

    /// Users who may sign in to make changes
    ///
    /// When a users file exists, anyone can still browse, but creating,
    /// editing or deleting recipes and changing the pantry or the shopping
    /// list needs a signed-in user. Without one the server is open to anyone
    /// who can reach it. Defaults to the COOK_USERS_FILE environment variable
    /// when set, otherwise users.toml in the configuration directory. Manage
    /// it with `cook server user`.
    #[arg(long, value_name = "PATH", value_hint = clap::ValueHint::FilePath)]
    users_file: Option<Utf8PathBuf>,

    /// Show visitors who are not signed in only the recipes
    ///
    /// They can browse, search and read recipes and menus, and pick a
    /// language, but get no shopping list, pantry, editor, preferences or
    /// API beyond reading recipes; the rest asks them to sign in. Signed-in
    /// users keep everything their role allows. Without a users file nobody
    /// can sign in, so this applies to every visitor. Also turned on by
    /// setting COOK_RECIPES_ONLY to 1, true or yes.
    #[arg(long)]
    recipes_only: bool,
}

#[derive(Debug, Subcommand)]
enum ServerCommand {
    /// Manage the users who can sign in to make changes
    ///
    /// Examples:
    ///   cook server user add alice      # Prompt for alice's password
    ///   cook server user passwd alice   # Change it
    ///   cook server user remove alice
    ///   cook server user list
    User(auth::cli::UserArgs),
    /// Print an argon2 hash of a password, for editing users.toml by hand
    HashPassword,
}

impl ServerArgs {
    pub fn get_base_path(&self) -> Option<Utf8PathBuf> {
        self.base_path.clone()
    }
}

pub fn run(ctx: Context, mut args: ServerArgs) -> Result<()> {
    match args.command.take() {
        Some(ServerCommand::User(user_args)) => auth::cli::run_user(user_args),
        Some(ServerCommand::HashPassword) => auth::cli::run_hash_password(),
        None => serve(ctx, args),
    }
}

#[tokio::main]
async fn serve(ctx: Context, args: ServerArgs) -> Result<()> {
    let addr = match args.host {
        Some(Some(addr)) => addr,
        Some(None) => "::".parse()?,
        None => [127, 0, 0, 1].into(),
    };
    let addr = SocketAddr::from((addr, args.port));
    let open = args.open;

    // Validate before binding or printing anything, so a bad flag combination
    // fails immediately rather than after the "Listening on ..." banner.
    let cors = Arc::new(cors::CorsConfig::from_args(
        &cors_origins(&args),
        args.cors_allow_credentials,
    )?);

    let listens_beyond_localhost = args.host.is_some();
    let state = build_state(ctx, args, Arc::clone(&cors))?;

    if state.url_prefix.is_empty() {
        println!("Listening on http://{addr}");
    } else {
        println!("Listening on http://{addr}{}", state.url_prefix);
    }

    // #[cfg(feature = "ui")]
    if open {
        let url = format!("http://{addr}{}", state.url_prefix);
        println!("Serving Web UI on {url}");
        tokio::task::spawn(async move {
            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
            if let Err(e) = open::that(url) {
                tracing::error!("Could not open the web browser: {e}");
            }
        });
    }

    // Start sync if already logged in (non-fatal: server starts regardless)
    #[cfg(feature = "sync")]
    {
        let session_guard = state.sync_session.lock().unwrap();
        if let Some(ref session) = *session_guard {
            match crate::sync::sync_db_path() {
                Ok(db_path) => {
                    match crate::sync::start_sync(session, state.base_path.to_string(), db_path) {
                        Ok(handle) => {
                            *state.last_sync_reason.lock().unwrap() = None;
                            // Safe to use try_lock here: no contention before the server accepts connections
                            if let Ok(mut guard) = state.sync_handle.try_lock() {
                                *guard = Some(handle);
                            }
                            tracing::info!("Sync started on server boot");
                        }
                        Err(e) => tracing::warn!("Failed to start sync: {e}"),
                    }
                }
                Err(e) => tracing::warn!("Failed to resolve sync DB path: {e}"),
            }
        }

        // Start token refresh task (cancelled on shutdown via shutdown_token)
        crate::sync::runner::start_token_refresh(
            Arc::clone(&state.sync_session),
            state.session_path.clone(),
            state.shutdown_token.child_token(),
        );
    }

    println!("Serving recipe files from: {:?}", state.base_path);
    match &state.auth {
        Some(auth) => {
            let users = auth.users();
            println!(
                "Sign-in: on, {} user(s) in {}. Guests can browse but not make changes.",
                users.len(),
                auth.users_path()
            );
            if users.is_empty() {
                println!("No users are listed, so nobody can make changes.");
            }
            if !state.csrf_check {
                tracing::warn!(
                    "--no-csrf-check with sign-in on: signed-in browsers are protected from \
                     other sites only by their cookie's SameSite=Lax"
                );
            }
        }
        None if listens_beyond_localhost => println!(
            "Sign-in: off. Anyone who can reach this server can change your recipes; \
             add a user with `cook server user add <name>` to require sign-in."
        ),
        None => {}
    }
    if state.recipes_only {
        println!(
            "Recipes only: {} only browse recipes.",
            if state.auth.is_some() {
                "guests"
            } else {
                "visitors"
            }
        );
    }

    // Maximum request body size: 1MB (reasonable for recipe files)
    const MAX_BODY_SIZE: usize = 1024 * 1024;

    let inner = Router::new()
        .nest("/api", api(&state)?)
        .merge(ui::ui())
        .route("/static/{*file}", get(serve_static))
        // `no-cache` makes the browser ask again each time, which ServeDir
        // answers with a 304 unless the file changed. Without it a title
        // picture replaced under the same name keeps showing the old one.
        .nest_service(
            "/api/static",
            SetResponseHeader::overriding(
                ServeDir::new(&state.base_path),
                header::CACHE_CONTROL,
                HeaderValue::from_static("no-cache"),
            ),
        )
        // Inside the prefix nest, so it sees paths without the prefix; after
        // every route, so it covers them all.
        .layer(axum::middleware::from_fn_with_state(
            Arc::clone(&state),
            auth::middleware::middleware,
        ));

    let app = if state.url_prefix.is_empty() {
        inner
    } else {
        Router::new().nest(&state.url_prefix, inner)
    };

    // Capture url_prefix before state is consumed by with_state.
    let url_prefix_for_features = state.url_prefix.clone();
    let csrf_check = state.csrf_check;

    #[cfg(feature = "sync")]
    let state_for_shutdown = state.clone();

    let cors_layer = cors.layer();

    let app = app
        .with_state(state)
        .layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
        .layer(axum::middleware::from_fn_with_state(
            url_prefix_for_features,
            crate::web::language::features_middleware,
        ))
        .layer(axum::middleware::from_fn(
            crate::web::language::language_middleware,
        ));

    // Inside the CORS layer, so preflights are answered before it runs.
    // `--no-csrf-check` omits it entirely.
    let app = if csrf_check {
        app.layer(axum::middleware::from_fn_with_state(
            Arc::clone(&cors),
            cors::write_guard,
        ))
    } else {
        app
    };

    // No `--cors-origin`, no CORS headers: see `cors::CorsOrigins::None`.
    let app = match cors_layer {
        Some(cors_layer) => app.layer(cors_layer),
        None => app,
    };

    // Outside everything, so a host name the server does not trust gets no
    // answer at all, not even to a preflight. `--no-csrf-check` omits it too.
    let app = if csrf_check {
        app.layer(axum::middleware::from_fn_with_state(cors, cors::host_guard))
    } else {
        app
    };

    let listener = match tokio::net::TcpListener::bind(&addr).await {
        Ok(listener) => listener,
        Err(e) => {
            if e.kind() == std::io::ErrorKind::AddrInUse {
                error!("Port {} is already in use. Please stop the existing server or use a different port with --port", addr.port());
                return Err(anyhow::anyhow!("Port {} is already in use", addr.port()));
            } else {
                error!("Failed to bind to {}: {}", addr, e);
                return Err(anyhow::anyhow!("Failed to bind to {}: {}", addr, e));
            }
        }
    };

    // NormalizePath trims trailing slashes so /cook/ matches /cook
    let app = tower_http::normalize_path::NormalizePath::trim_trailing_slash(app);

    axum::serve(
        listener,
        axum::ServiceExt::<axum::extract::Request>::into_make_service(app),
    )
    .with_graceful_shutdown(shutdown_signal())
    .await
    .context("Server error")?;

    // Cancel background tasks (token refresh) and stop sync on shutdown
    #[cfg(feature = "sync")]
    {
        state_for_shutdown.shutdown_token.cancel();
        if let Some(handle) = state_for_shutdown.sync_handle.lock().await.take() {
            handle.stop().await;
        }
    }

    info!("Server stopped");

    Ok(())
}

/// The `--cors-origin` values to use: the flag when it is given, otherwise
/// `COOK_CORS_ORIGIN`.
///
/// The flag wins, so a command line can override whatever an image or a shell
/// profile put in the environment. There is deliberately no equivalent for
/// `--no-csrf-check`: turning the check off should take a command someone
/// typed, not a variable inherited from somewhere else.
fn cors_origins(args: &ServerArgs) -> Vec<String> {
    if !args.cors_origin.is_empty() {
        return args.cors_origin.clone();
    }
    std::env::var(cors::ORIGIN_ENV)
        .map(|value| cors::origins_from_env(&value))
        .unwrap_or_default()
}

/// Turns `--recipes-only` on from the environment, for containers.
const RECIPES_ONLY_ENV: &str = "COOK_RECIPES_ONLY";

/// Whether `--recipes-only` is on: the flag, or `COOK_RECIPES_ONLY`.
fn recipes_only(args: &ServerArgs) -> bool {
    args.recipes_only || std::env::var(RECIPES_ONLY_ENV).is_ok_and(|value| env_flag(&value))
}

/// Whether an environment variable's value turns its switch on. An empty or
/// unrecognised value leaves it off.
fn env_flag(value: &str) -> bool {
    ["1", "true", "yes"]
        .iter()
        .any(|on| value.trim().eq_ignore_ascii_case(on))
}

fn build_state(
    ctx: Context,
    args: ServerArgs,
    cors: Arc<cors::CorsConfig>,
) -> Result<Arc<AppState>> {
    let recipes_only = recipes_only(&args);
    let base_path = ctx.base_path().to_path_buf();

    let path = args.base_path.as_ref().unwrap_or(&base_path);
    let absolute_path = resolve_to_absolute_path(path)?;

    if absolute_path.is_file() {
        bail!("Base path {} is not a directory", absolute_path);
    }

    tracing::info!("Using absolute base path: {:?}", absolute_path);

    // Normalize URL prefix: strip trailing slashes, ensure leading slash
    let url_prefix = match args.url_prefix {
        Some(prefix) => {
            let prefix = prefix.trim_end_matches('/').to_string();
            if !prefix.starts_with('/') {
                bail!("URL prefix must start with '/', got: {prefix}");
            }
            if !prefix
                .chars()
                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.'))
            {
                bail!("URL prefix contains invalid characters: {prefix}");
            }
            prefix
        }
        None => String::new(),
    };

    // Create a new Context with the actual base path to properly search for config files
    let server_ctx = Context::discover(absolute_path.clone());
    let aisle_path = server_ctx.aisle().path().map(camino::Utf8Path::to_path_buf);
    let pantry_path = server_ctx
        .pantry()
        .path()
        .map(camino::Utf8Path::to_path_buf);

    // Before anything is started, so a bad users file stops the server
    // without leaving a watcher behind.
    let auth = auth::setup(args.users_file.as_deref(), &absolute_path)?;

    tracing::info!("Aisle configuration: {:?}", aisle_path);
    tracing::info!("Pantry configuration: {:?}", pantry_path);

    let shopping_list_events = match shopping_list_watcher::spawn(absolute_path.clone()) {
        Ok(tx) => Some(tx),
        Err(e) => {
            tracing::warn!("Failed to start shopping list watcher; live updates disabled: {e:#}");
            None
        }
    };

    #[cfg(feature = "sync")]
    let (session_path, session) = {
        let path = cookcli_core::global_config_path("session.json")
            .map(|p: Utf8PathBuf| p.into_std_path_buf())
            .unwrap_or_else(|_| std::path::PathBuf::from(".cook-session.json"));
        let session = match crate::sync::SyncSession::load(&path) {
            Ok(s) => s,
            Err(e) => {
                tracing::warn!("Failed to load sync session: {e}");
                None
            }
        };
        (path, session)
    };

    Ok(Arc::new(AppState {
        base_path: absolute_path,
        aisle_path,
        pantry_path,
        url_prefix,
        csrf_check: args.csrf_check,
        cors,
        auth,
        recipes_only,
        lsp_sessions: lsp_bridge::SessionLimit::new(args.max_lsp_sessions),
        checked_log_lock: Arc::new(tokio::sync::Mutex::new(())),
        aisle_lock: tokio::sync::Mutex::new(()),
        shopping_list_events,
        #[cfg(feature = "sync")]
        sync_session: Arc::new(Mutex::new(session)),
        #[cfg(feature = "sync")]
        sync_handle: Arc::new(tokio::sync::Mutex::new(None)),
        #[cfg(feature = "sync")]
        last_sync_reason: Arc::new(Mutex::new(None)),
        #[cfg(feature = "sync")]
        pending_device_flow: Arc::new(tokio::sync::Mutex::new(None)),
        #[cfg(feature = "sync")]
        session_path,
        #[cfg(feature = "sync")]
        shutdown_token: tokio_util::sync::CancellationToken::new(),
    }))
}

async fn shutdown_signal() {
    let ctrl_c = async {
        tokio::signal::ctrl_c()
            .await
            .expect("failed to install Ctrl+C handler");
    };

    #[cfg(unix)]
    let terminate = async {
        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
            .expect("failed to install signal handler")
            .recv()
            .await;
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    };

    info!("Stopping server");
}

pub struct AppState {
    pub base_path: Utf8PathBuf,
    pub aisle_path: Option<Utf8PathBuf>,
    pub pantry_path: Option<Utf8PathBuf>,
    pub url_prefix: String,
    /// When true, every request must be sent to a host `cors` trusts, and
    /// browser requests that modify recipes, and browsers opening the LSP
    /// websocket, must come from an origin it trusts: the server's own
    /// address, or a `--cors-origin`. Cleared by `--no-csrf-check`.
    pub csrf_check: bool,
    /// The `--cors-origin` policy. Besides CORS, it decides which origins may
    /// modify recipes and open the LSP websocket, for the write guard, the
    /// new-recipe form and the bridge alike.
    pub cors: Arc<cors::CorsConfig>,
    /// Sign-in, when a users file exists. `None` leaves the server open to
    /// anyone who can reach it.
    pub auth: Option<Arc<auth::Auth>>,
    /// `--recipes-only`: whoever is not signed in only browses recipes.
    pub recipes_only: bool,
    /// How many LSP websockets — and so how many `cook lsp` subprocesses —
    /// may run at once. Set by `--max-lsp-sessions`.
    pub lsp_sessions: lsp_bridge::SessionLimit,
    /// Serializes access to `.shopping-checked` within this process.
    /// File-level `flock` doesn't prevent two tasks in the *same* process
    /// from racing on the file (the kernel treats them as one lock owner),
    /// so we need an in-process mutex on top. All check / uncheck / compact
    /// handlers acquire this before touching the checked log.
    pub checked_log_lock: Arc<tokio::sync::Mutex<()>>,
    /// Serializes changes to the aisle file, each a read, an edit and a
    /// write that must not interleave with another.
    pub aisle_lock: tokio::sync::Mutex<()>,
    /// Broadcasts filesystem changes to `.shopping-list` / `.shopping-checked`
    /// to every open SSE subscriber. `None` means watcher init failed; SSE
    /// clients can still connect but will never receive events.
    pub shopping_list_events: Option<shopping_list_watcher::ChangeSender>,
    #[cfg(feature = "sync")]
    pub sync_session: Arc<Mutex<Option<crate::sync::SyncSession>>>,
    #[cfg(feature = "sync")]
    pub sync_handle: Arc<tokio::sync::Mutex<Option<crate::sync::SyncHandle>>>,
    /// Reason the most recent sync task stopped, surfaced by the status
    /// endpoint after the (now finished) `SyncHandle` that observed it has
    /// been cleaned up. Cleared whenever a new sync task starts or the user
    /// logs out.
    #[cfg(feature = "sync")]
    pub last_sync_reason: Arc<Mutex<Option<crate::sync::SyncFailureReason>>>,
    #[cfg(feature = "sync")]
    pub pending_device_flow: Arc<tokio::sync::Mutex<Option<crate::sync::PendingDeviceFlow>>>,
    #[cfg(feature = "sync")]
    pub session_path: std::path::PathBuf,
    #[cfg(feature = "sync")]
    pub shutdown_token: tokio_util::sync::CancellationToken,
}

impl AppState {
    /// The aisle file the shopping list uses: the one found at startup, or
    /// else `config/aisle.conf` in the recipe directory once it exists, so a
    /// file created from the aisles page, or by hand, counts without a
    /// restart.
    pub fn aisle_file(&self) -> Option<Utf8PathBuf> {
        self.aisle_path.clone().or_else(|| {
            let local = self.local_aisle_file();
            local.is_file().then_some(local)
        })
    }

    /// Where the aisles page creates an aisle file when there is none.
    pub fn local_aisle_file(&self) -> Utf8PathBuf {
        self.base_path.join("config").join("aisle.conf")
    }
}

#[cfg(feature = "sync")]
impl AppState {
    /// Check sync status: returns (logged_in, email, syncing, reason).
    /// `reason` explains why sync is off (e.g. `"payment_required"`), and is
    /// `None` while syncing or when the last stop wasn't a known condition.
    /// Cleans up finished sync handles as a side effect.
    pub async fn sync_status(&self) -> (bool, Option<String>, bool, Option<String>) {
        let (logged_in, email) = {
            let session = self.sync_session.lock().unwrap();
            (
                session.is_some(),
                session.as_ref().and_then(|s| s.email.clone()),
            )
        };
        let syncing = {
            let mut guard = self.sync_handle.lock().await;
            match guard.as_ref() {
                Some(handle) if handle.is_running() => true,
                Some(handle) => {
                    if let Some(reason) = handle.last_error_reason() {
                        *self.last_sync_reason.lock().unwrap() = Some(reason);
                    }
                    guard.take();
                    false
                }
                None => false,
            }
        };
        let reason = if syncing {
            None
        } else {
            self.last_sync_reason
                .lock()
                .unwrap()
                .map(|r| r.as_str().to_string())
        };
        (logged_in, email, syncing, reason)
    }
}

fn api(_state: &AppState) -> Result<Router<Arc<AppState>>> {
    let router = Router::new()
        .route("/shopping_list", post(handlers::shopping_list))
        .route(
            "/shopping_list/items",
            get(handlers::get_shopping_list_items),
        )
        .route("/shopping_list/add", post(handlers::add_to_shopping_list))
        .route(
            "/shopping_list/add_menu",
            post(handlers::add_menu_to_shopping_list),
        )
        .route(
            "/shopping_list/remove",
            post(handlers::remove_from_shopping_list),
        )
        .route("/shopping_list/clear", post(handlers::clear_shopping_list))
        .route("/shopping_list/check", post(handlers::check_shopping_item))
        .route(
            "/shopping_list/uncheck",
            post(handlers::uncheck_shopping_item),
        )
        .route("/shopping_list/checked", get(handlers::get_checked_items))
        .route("/shopping_list/compact", post(handlers::compact_checked))
        .route("/shopping_list/events", get(handlers::shopping_list_events))
        .route("/pantry", get(handlers::get_pantry))
        .route("/pantry/add", post(handlers::add_pantry_item))
        .route("/pantry/expiring", get(handlers::get_expiring))
        .route("/pantry/depleted", get(handlers::get_depleted))
        .route(
            "/pantry/{section}/{name}",
            axum::routing::delete(handlers::remove_pantry_item),
        )
        .route(
            "/pantry/{section}/{name}",
            axum::routing::put(handlers::update_pantry_item),
        )
        .route(
            "/aisles",
            get(handlers::get_aisles).post(handlers::create_aisles),
        )
        .route("/aisles/changes", post(handlers::change_aisles))
        .route(
            "/aisles/raw",
            get(handlers::get_raw_aisles).put(handlers::put_raw_aisles),
        )
        .route("/aisles/uncategorized", get(handlers::get_uncategorized))
        .route("/recipes", get(handlers::all_recipes))
        .route("/recipes/raw/{*path}", get(handlers::recipe_raw)) // More specific route must come first
        .route(
            "/recipes/{*path}",
            get(handlers::recipe)
                .put(handlers::recipe_save)
                .delete(handlers::recipe_delete),
        )
        .route(
            "/recipe_image/{*path}",
            get(handlers::recipe_image_get)
                .put(handlers::recipe_image_put)
                .delete(handlers::recipe_image_delete)
                // A phone photo is well past the 1 MiB every other route is
                // held to; this layer sits inside that one, so it wins.
                .layer(DefaultBodyLimit::max(title_image::MAX_UPLOAD_BYTES)),
        )
        .route("/recipe_rename/{*path}", post(handlers::recipe_rename))
        .route("/menus", get(handlers::list_menus))
        .route("/menus/{*path}", get(handlers::get_menu))
        .route("/plans/{*path}", post(handlers::plan_edit))
        .route("/search", get(handlers::search))
        .route("/stats", get(handlers::stats))
        .route("/reload", get(handlers::reload).post(handlers::reload))
        .route("/ws/lsp", get(lsp_bridge::lsp_websocket));

    #[cfg(feature = "sync")]
    let router = router
        .route("/sync/status", get(handlers::sync_status))
        .route("/sync/login", post(handlers::sync_login))
        .route("/sync/cancel_login", post(handlers::sync_cancel_login))
        .route("/sync/logout", post(handlers::sync_logout));

    Ok(router)
}

async fn serve_static(Path(path): Path<String>) -> impl axum::response::IntoResponse {
    let path = path.trim_start_matches('/');

    crate::web::StaticFiles::get(path)
        .map(|content| {
            let mime = mime_guess::from_path(path).first_or_octet_stream();
            Response::builder()
                .status(StatusCode::OK)
                .header(header::CONTENT_TYPE, mime.as_ref())
                .body(Body::from(content.data))
                .unwrap()
        })
        .unwrap_or_else(|| {
            Response::builder()
                .status(StatusCode::NOT_FOUND)
                .body(Body::from("404 Not Found"))
                .unwrap()
        })
}

/// `path` canonicalized, or, when it does not exist yet, its nearest existing
/// ancestor canonicalized with the rest appended.
fn canonical_or_nearest(path: &Utf8Path) -> Utf8PathBuf {
    let mut missing = Vec::new();
    let mut current = path;
    loop {
        if let Ok(canonical) = current.canonicalize_utf8() {
            return missing
                .iter()
                .rev()
                .fold(canonical, |acc: Utf8PathBuf, part| acc.join(part));
        }
        match (current.parent(), current.file_name()) {
            (Some(parent), Some(name)) => {
                missing.push(name);
                current = parent;
            }
            _ => return path.to_path_buf(),
        }
    }
}

#[cfg(test)]
mod tests {
    use crate::args::{CliArgs, Command};
    use clap::{CommandFactory, Parser};

    #[test]
    fn cli_definition_is_valid() {
        CliArgs::command().debug_assert();
    }

    #[test]
    fn recipes_only_env_values() {
        for value in ["1", "true", "TRUE", "yes", " Yes "] {
            assert!(super::env_flag(value), "{value:?}");
        }
        for value in ["", "0", "false", "no", "off", "2"] {
            assert!(!super::env_flag(value), "{value:?}");
        }
    }

    /// `cook server` takes an optional recipe directory *and* subcommands,
    /// so check clap tells them apart.
    #[test]
    fn subcommands_and_recipe_directory_are_told_apart() {
        let parse = |args: &[&str]| match CliArgs::try_parse_from(args).unwrap().command {
            Command::Server(server) => server,
            _ => panic!("not the server command"),
        };

        let serve = parse(&["cook", "server", "./recipes"]);
        assert!(serve.command.is_none());
        assert_eq!(
            serve.base_path.as_deref().map(|p| p.as_str()),
            Some("./recipes")
        );

        assert!(matches!(
            parse(&["cook", "server", "user", "add", "alice"]).command,
            Some(super::ServerCommand::User(_))
        ));
        assert!(matches!(
            parse(&["cook", "server", "hash-password"]).command,
            Some(super::ServerCommand::HashPassword)
        ));
    }
}