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
//! # Espresso Configuration

use crate::files::directory::{index::IndexStrategy, listing::default_listing_renderer};
use serde_derive::{Deserialize, Serialize};
use snafu::Snafu;
use std::{
  collections::{HashMap, HashSet},
  net::SocketAddr,
  path::PathBuf,
  sync::Arc,
};

#[derive(Snafu, Debug)]
pub enum Error {
  /// The configuration file could not be found or read.
  #[snafu(display("Could not open config from {}: {}", path.display(), source))]
  OpenConfig {
    path: PathBuf,
    source: std::io::Error,
  },
  /// The configuration file could not be parsed or deserialized.
  #[snafu(display("Could not deserialize config from {}: {}", path.display(), source))]
  DeserializeConfig {
    path: PathBuf,
    source: toml::de::Error,
  },
  /// The current directory could not be determined.
  GetCurrentDir { source: std::io::Error },
  /// None of the provided paths were valid configuration files.
  #[snafu(display("None of the provided paths were valid configuration files."))]
  NoValidPath,
}

pub type Result<T, E = Error> = std::result::Result<T, E>;

/// Root configuration struct for Espresso servers.
#[derive(Serialize, Deserialize, PartialEq, Debug)]
pub struct Config {
  pub bundle: BundleConfig,
  pub unbundler: UnbundlerConfig,
  pub server: ServerConfig,
  /// Optional stats server config.
  pub stats: Option<StatsConfig>,
}

impl Default for Config {
  fn default() -> Self {
    Config {
      bundle: BundleConfig::LocalBundle {
        dir: PathBuf::from("public"),
      },
      unbundler: UnbundlerConfig { poll_seconds: 30 },
      server: Default::default(),
      stats: None,
    }
  }
}

/// Main server configuration.
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
pub struct ServerConfig {
  /// Server listed address.
  ///
  /// e.g. 127.0.0.1:8080
  #[serde(default = "default_address")]
  pub address: SocketAddr,
  /// Absolute or relative path to be used as a run directory.
  ///
  /// The path will be created if it doesn't exist.
  #[serde(default = "default_run_dir")]
  pub run_dir: PathBuf,
  /// Whether to allow the Espresso server to automatically clean up the run
  /// directory.
  ///
  /// This is generally desirable, but can be disbaled for
  /// debugging/development.
  pub auto_cleanup: Option<bool>,
  /// Specifies how the server should handle directory paths.
  pub index_strategy: Option<IndexStrategyConfig>,
  /// Specifies whether the server redirects directory paths to always end with
  /// a slash.
  pub redirect_to_slash: Option<bool>,
  /// Specifies the file that the server should return when the specified path
  /// can't be found.
  ///
  /// If not specified, the server will return a 404 response with no body.
  pub not_found_path: Option<PathBuf>,
  /// Specifies what kind of compression will be applied to server responses.
  pub compression: Option<CompressionConfig>,
  /// Specifies whether the server should compute ETags and include them in the
  /// response.
  ///
  /// Note: This only applies to file resources.
  pub set_etag: Option<bool>,
  /// Specifies whether the server should include a `Last-Modified` header in
  /// responses.
  ///
  /// Note: This only applies to file resources.
  pub set_last_modified: Option<bool>,
  /// Optional overrides for the Content-Disposition header on some Mime types.
  pub mime_disposition: Option<HashMap<String, ContentDispositionConfig>>,
  /// Specifies additional headers to include as part of the response.
  pub headers: Option<HashMap<String, String>>,
  /// Path-specific configurations.
  pub path_configs: Option<Vec<PathConfig>>,
}

impl Default for ServerConfig {
  fn default() -> Self {
    ServerConfig {
      address: default_address(),
      run_dir: default_run_dir(),
      auto_cleanup: None,
      index_strategy: None,
      redirect_to_slash: None,
      not_found_path: None,
      compression: None,
      set_etag: None,
      set_last_modified: None,
      mime_disposition: None,
      headers: None,
      path_configs: None,
    }
  }
}

fn default_address() -> SocketAddr {
  "127.0.0.1:8080".parse().unwrap()
}

fn default_run_dir() -> PathBuf {
  "run".into()
}

/// Per-path configuration.
///
/// Server configuration that can be set depending on the current path.
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
pub struct PathConfig {
  /// Specifies how the path should be matched.
  pub matcher: PathMatcherConfig,
  /// Optional overrides for the Content-Disposition header on some Mime types.
  pub mime_disposition: Option<HashMap<String, ContentDispositionConfig>>,
  /// Specifies whether the server should compute ETags and include them in the
  /// response.
  ///
  /// Note: This only applies to file resources.
  pub set_etag: Option<bool>,
  /// Specifies whether the server should include a `Last-Modified` header in
  /// responses.
  ///
  /// Note: This only applies to file resources.
  pub set_last_modified: Option<bool>,
  /// Specifies how the server should handle directory paths.
  pub index_strategy: Option<IndexStrategyConfig>,
  /// Specifies additional headers to include as part of the response.
  pub headers: Option<HashMap<String, String>>,
  /// Specifies the file that the server should return when the specified path
  /// can't be found.
  ///
  /// If not specified, the server will return a 404 response with no body.
  pub not_found_path: Option<PathBuf>,
}

/// Patch matcher configuration.
///
/// Tells the server how to match a path.
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
#[serde(tag = "type")]
pub enum PathMatcherConfig {
  /// A simple exact text match. (Cheap)
  StaticMatcher { path: String },
  /// A prefix matcher.
  PrefixMatcher { prefix: String },
  /// A regex matcher. (Slightly more costly than exact text/prefix matches).
  RegexMatcher { pattern: String },
}

// Content Disposition type.
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
pub enum ContentDispositionConfig {
  Inline,
  Attachment,
}

/// Stats server configuration.
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
pub struct StatsConfig {
  /// Server listed address.
  ///
  /// Note: Should be different from the main server.
  pub address: SocketAddr,
}

/// Unbundler configuration.
///
/// Controls specifics about the unbundler behavior. For the actual bundle
/// configuration, use the `BundleConfig`.
#[derive(Serialize, Deserialize, PartialEq, Debug)]
pub struct UnbundlerConfig {
  pub poll_seconds: u64,
}

/// Bundle configuration.
///
/// Specifies where Espresso should retrieve the site bundle from.
#[derive(Serialize, Deserialize, PartialEq, Debug)]
#[serde(tag = "type")]
pub enum BundleConfig {
  /// A bundle that is retrieved from an S3-compatible API.
  ///
  /// The bundle is expected to be stored in a single tar archive. Espresso
  /// will poll the S3 API for any changes by comparing the eTag.
  S3Bundle {
    access_key: String,
    secret_key: String,
    endpoint: String,
    bucket: String,
    /// AWS Region
    ///
    /// This can be left blank for some implementations (Minio, Ceph, etc).
    region: String,
    object_name: String,
  },
  /// A mock-bundle. Uses the provided directory as the serve directory
  /// directly.
  LocalBundle { dir: PathBuf },
}

/// Tells the server how to handle a user request for a directory path.
#[derive(Clone, Serialize, Deserialize, PartialEq, Debug)]
#[serde(tag = "type")]
pub enum IndexStrategyConfig {
  /// Renders a directory listing when a direct path is requested.
  AlwaysShowListing,
  /// Attempts to retrieve one of the specified index files when a directory
  /// path is requested, otherwise, it renders a directory listing.
  ShowListingWhenAbsent { filenames: HashSet<String> },
  /// Attempts to retrieve one of the specified index files when a directory
  /// path is requested, otherwise, it returns a 404.
  IndexFiles { filenames: HashSet<String> },
  /// Returns a 404 if a directory path is requested.
  NoIndex,
}

/// Tells the server whether to compress responses or not.
#[derive(Clone, Serialize, Deserialize, PartialEq, Debug)]
pub enum CompressionConfig {
  /// Automatically select encoding based on encoding negotiation.
  Auto,
  /// Don't use any compression.
  Disabled,
}

impl From<IndexStrategyConfig> for IndexStrategy {
  fn from(config: IndexStrategyConfig) -> Self {
    match config {
      IndexStrategyConfig::AlwaysShowListing => IndexStrategy::AlwaysShowListing {
        renderer: Arc::new(default_listing_renderer),
      },
      IndexStrategyConfig::ShowListingWhenAbsent { filenames } => {
        IndexStrategy::ShowListingWhenAbsent {
          renderer: Arc::new(default_listing_renderer),
          filenames,
        }
      }
      IndexStrategyConfig::IndexFiles { filenames } => IndexStrategy::IndexFiles { filenames },
      IndexStrategyConfig::NoIndex => IndexStrategy::NoIndex,
    }
  }
}

#[cfg(test)]
mod tests {
  use collective::config::from_file;

  use super::{BundleConfig, Config, ServerConfig, StatsConfig, UnbundlerConfig};
  use std::path::{Path, PathBuf};

  #[test]
  fn test_from_file() {
    let config: Config = from_file(Path::new("config.sample.toml"), None).unwrap();

    assert_eq!(
      config,
      Config {
        stats: Some(StatsConfig {
          address: "127.0.0.1:8089".parse().unwrap(),
        }),
        bundle: BundleConfig::LocalBundle {
          dir: PathBuf::from("/tmp/")
        },
        server: ServerConfig {
          address: "127.0.0.1:8088".parse().unwrap(),
          run_dir: PathBuf::from("run"),
          auto_cleanup: None,
          index_strategy: None,
          redirect_to_slash: None,
          not_found_path: None,
          compression: None,
          set_etag: None,
          set_last_modified: None,
          mime_disposition: None,
          headers: None,
          path_configs: None,
        },
        unbundler: UnbundlerConfig { poll_seconds: 10 }
      }
    )
  }
}