Skip to content

Commit c8ce41c

Browse files
tobiasgegi0baro
andauthored
Add option to serve index files for directory listings (#784)
* Added --static-path-index-file option (#783) Setting this option will enable Granian to also serve directories from the static file mount using the given file as index file. * Code refactor * Update readme * Fix file tests --------- Co-authored-by: Giovanni Barillari <g@baro.dev>
1 parent dbce11c commit c8ce41c

19 files changed

Lines changed: 104 additions & 28 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,9 @@ Options:
324324
(/static)]
325325
--static-path-mount DIRECTORY Path to mount for static file serving [env
326326
var: GRANIAN_STATIC_PATH_MOUNT]
327+
--static-path-dir-to-file TEXT Serve the specified file as the index for
328+
directory listings [env var:
329+
GRANIAN_STATIC_PATH_DIR_TO_FILE]
327330
--static-path-expires DURATION Cache headers expiration (in seconds or a
328331
human-readable duration) for static file
329332
serving. 0 to disable. [env var:

granian/_granian.pyi

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ class ASGIWorker:
7171
http1_opts: HTTP1Settings | None,
7272
http2_opts: HTTP2Settings | None,
7373
websockets_enabled: bool,
74-
static_files: tuple[str, str, str] | None,
74+
static_files: tuple[str, str, str | None, str | None] | None,
7575
ssl_enabled: bool,
7676
ssl_cert: str | None,
7777
ssl_key: str | None,
@@ -95,7 +95,7 @@ class WSGIWorker:
9595
http_mode: str,
9696
http1_opts: HTTP1Settings | None,
9797
http2_opts: HTTP2Settings | None,
98-
static_files: tuple[str, str, str] | None,
98+
static_files: tuple[str, str, str | None, str | None] | None,
9999
ssl_enabled: bool,
100100
ssl_cert: str | None,
101101
ssl_key: str | None,
@@ -120,7 +120,7 @@ class RSGIWorker:
120120
http1_opts: HTTP1Settings | None,
121121
http2_opts: HTTP2Settings | None,
122122
websockets_enabled: bool,
123-
static_files: tuple[str, str, str] | None,
123+
static_files: tuple[str, str, str | None, str | None] | None,
124124
ssl_enabled: bool,
125125
ssl_cert: str | None,
126126
ssl_key: str | None,

granian/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,11 @@ def option(*param_decls: str, cls: type[click.Option] | None = None, **attrs: An
354354
type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True, path_type=pathlib.Path),
355355
help='Path to mount for static file serving',
356356
)
357+
@option(
358+
'--static-path-dir-to-file',
359+
default=None,
360+
help='Serve the specified file as the index for directory listings',
361+
)
357362
@option(
358363
'--static-path-expires',
359364
type=Duration(0),
@@ -482,6 +487,7 @@ def cli(
482487
env_files: list[pathlib.Path] | None,
483488
static_path_route: str,
484489
static_path_mount: pathlib.Path | None,
490+
static_path_dir_to_file: str | None,
485491
static_path_expires: int,
486492
metrics_enabled: bool,
487493
metrics_scrape_interval: int,
@@ -571,6 +577,7 @@ def cli(
571577
env_files=env_files,
572578
static_path_route=static_path_route,
573579
static_path_mount=static_path_mount,
580+
static_path_dir_to_file=static_path_dir_to_file,
574581
static_path_expires=static_path_expires,
575582
metrics_enabled=metrics_enabled,
576583
metrics_scrape_interval=metrics_scrape_interval,

granian/server/common.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ def __init__(
127127
env_files: Sequence[Path] | None = None,
128128
static_path_route: str = '/static',
129129
static_path_mount: Path | None = None,
130+
static_path_dir_to_file: str | None = None,
130131
static_path_expires: int = 86400,
131132
metrics_enabled: bool = False,
132133
metrics_scrape_interval: int = 15,
@@ -189,6 +190,7 @@ def __init__(
189190
(
190191
static_path_route,
191192
str(static_path_mount.resolve()),
193+
static_path_dir_to_file,
192194
(str(static_path_expires) if static_path_expires else None),
193195
)
194196
if static_path_mount

granian/server/embed.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ def __init__(
126126
factory: bool = False,
127127
static_path_route: str = '/static',
128128
static_path_mount: Path | None = None,
129+
static_path_dir_to_file: str | None = None,
129130
static_path_expires: int = 86400,
130131
):
131132
super().__init__(
@@ -161,6 +162,7 @@ def __init__(
161162
factory=factory,
162163
static_path_route=static_path_route,
163164
static_path_mount=static_path_mount,
165+
static_path_dir_to_file=static_path_dir_to_file,
164166
static_path_expires=static_path_expires,
165167
)
166168
self.main_loop_interrupt = asyncio.Event()
@@ -213,7 +215,7 @@ async def _spawn_asgi_worker(
213215
http1_settings: HTTP1Settings | None,
214216
http2_settings: HTTP2Settings | None,
215217
websockets: bool,
216-
static_path: tuple[str, str, str] | None,
218+
static_path: tuple[str, str, str | None, str | None] | None,
217219
log_access_fmt: str | None,
218220
ssl_ctx: SSLCtx,
219221
scope_opts: dict[str, Any],
@@ -259,7 +261,7 @@ async def _spawn_asgi_lifespan_worker(
259261
http1_settings: HTTP1Settings | None,
260262
http2_settings: HTTP2Settings | None,
261263
websockets: bool,
262-
static_path: tuple[str, str, str] | None,
264+
static_path: tuple[str, str, str | None, str | None] | None,
263265
log_access_fmt: str | None,
264266
ssl_ctx: SSLCtx,
265267
scope_opts: dict[str, Any],
@@ -314,7 +316,7 @@ async def _spawn_rsgi_worker(
314316
http1_settings: HTTP1Settings | None,
315317
http2_settings: HTTP2Settings | None,
316318
websockets: bool,
317-
static_path: tuple[str, str, str] | None,
319+
static_path: tuple[str, str, str | None, str | None] | None,
318320
log_access_fmt: str | None,
319321
ssl_ctx: SSLCtx,
320322
scope_opts: dict[str, Any],

granian/server/mp.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ def _spawn_asgi_worker(
129129
http1_settings: HTTP1Settings | None,
130130
http2_settings: HTTP2Settings | None,
131131
websockets: bool,
132-
static_path: tuple[str, str, str | None] | None,
132+
static_path: tuple[str, str, str | None, str | None] | None,
133133
log_access_fmt: str | None,
134134
ssl_ctx: SSLCtx,
135135
scope_opts: dict[str, Any],

src/asgi/serve.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ impl ASGIWorker {
5656
http1_opts: Option<Py<PyAny>>,
5757
http2_opts: Option<Py<PyAny>>,
5858
websockets_enabled: bool,
59-
static_files: Option<(String, String, Option<String>)>,
59+
static_files: Option<(String, String, Option<String>, Option<String>)>,
6060
ssl_enabled: bool,
6161
ssl_cert: Option<String>,
6262
ssl_key: Option<String>,

src/files.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,28 @@ use tokio_util::io::ReaderStream;
1313
use crate::http::{HTTPResponse, HV_SERVER, response_404};
1414

1515
#[inline(always)]
16-
pub(crate) fn match_static_file(uri_path: &str, prefix: &str, mount_point: &str) -> Option<Result<String>> {
16+
pub(crate) fn match_static_file(
17+
uri_path: &str,
18+
prefix: &str,
19+
mount_point: &str,
20+
dir_to_file: Option<&String>,
21+
) -> Option<Result<String>> {
1722
let decoded_uri_path = percent_decode_str(uri_path).decode_utf8_lossy();
1823
if let Some(file_path) = decoded_uri_path.strip_prefix(prefix) {
1924
#[cfg(not(windows))]
2025
let fpath = format!("{mount_point}{file_path}");
2126
#[cfg(windows)]
2227
let fpath = format!("{mount_point}{}", file_path.replace("/", "\\"));
2328
match Path::new(&fpath).canonicalize() {
24-
Ok(full_path) => {
29+
Ok(mut full_path) => {
30+
if full_path.is_dir() {
31+
match dir_to_file {
32+
Some(rewrite_file) => {
33+
full_path = full_path.join(rewrite_file);
34+
}
35+
None => return Some(Err(anyhow::anyhow!("dir"))),
36+
}
37+
}
2538
#[cfg(windows)]
2639
let full_path = &full_path.display().to_string()[4..];
2740
if full_path.starts_with(mount_point) {

src/rsgi/serve.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ impl RSGIWorker {
5656
http1_opts: Option<Py<PyAny>>,
5757
http2_opts: Option<Py<PyAny>>,
5858
websockets_enabled: bool,
59-
static_files: Option<(String, String, Option<String>)>,
59+
static_files: Option<(String, String, Option<String>, Option<String>)>,
6060
ssl_enabled: bool,
6161
ssl_cert: Option<String>,
6262
ssl_key: Option<String>,

src/workers.rs

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ pub(crate) struct WorkerConfig {
102102
pub http1_opts: HTTP1Config,
103103
pub http2_opts: HTTP2Config,
104104
pub websockets_enabled: bool,
105-
pub static_files: Option<(String, String, Option<String>)>,
105+
pub static_files: Option<(String, String, Option<String>, Option<String>)>,
106106
pub tls_opts: Option<WorkerTlsConfig>,
107107
pub metrics: (
108108
Option<std::time::Duration>,
@@ -134,7 +134,7 @@ impl WorkerConfig {
134134
http1_opts: HTTP1Config,
135135
http2_opts: HTTP2Config,
136136
websockets_enabled: bool,
137-
static_files: Option<(String, String, Option<String>)>,
137+
static_files: Option<(String, String, Option<String>, Option<String>)>,
138138
ssl_enabled: bool,
139139
ssl_cert: Option<String>,
140140
ssl_key: Option<String>,
@@ -266,21 +266,23 @@ pub(crate) struct WorkerCTXFiles<M> {
266266
pub metrics: M,
267267
pub static_prefix: String,
268268
pub static_mount: String,
269+
pub static_dir_to_file: Option<String>,
269270
pub static_expires: Option<String>,
270271
}
271272

272273
impl<M> WorkerCTXFiles<M> {
273274
pub fn new(
274275
callback: crate::callbacks::PyCBScheduler,
275276
metrics: M,
276-
files: Option<(String, String, Option<String>)>,
277+
files: Option<(String, String, Option<String>, Option<String>)>,
277278
) -> Self {
278-
let (static_prefix, static_mount, static_expires) = files.unwrap();
279+
let (static_prefix, static_mount, static_dir_to_file, static_expires) = files.unwrap();
279280
Self {
280281
callback: Arc::new(callback),
281282
metrics,
282283
static_prefix,
283284
static_mount,
285+
static_dir_to_file,
284286
static_expires,
285287
}
286288
}
@@ -402,9 +404,12 @@ macro_rules! service_impl {
402404
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
403405

404406
fn call(&self, req: crate::http::HTTPRequest) -> Self::Future {
405-
if let Some(static_match) =
406-
crate::files::match_static_file(req.uri().path(), &self.ctx.static_prefix, &self.ctx.static_mount)
407-
{
407+
if let Some(static_match) = crate::files::match_static_file(
408+
req.uri().path(),
409+
&self.ctx.static_prefix,
410+
&self.ctx.static_mount,
411+
self.ctx.static_dir_to_file.as_ref(),
412+
) {
408413
if static_match.is_err() {
409414
return Box::pin(async move { Ok::<_, hyper::Error>(crate::http::response_404()) });
410415
}
@@ -477,9 +482,12 @@ macro_rules! service_impl {
477482
.req_handled
478483
.fetch_add(1, std::sync::atomic::Ordering::Release);
479484

480-
if let Some(static_match) =
481-
crate::files::match_static_file(req.uri().path(), &self.ctx.static_prefix, &self.ctx.static_mount)
482-
{
485+
if let Some(static_match) = crate::files::match_static_file(
486+
req.uri().path(),
487+
&self.ctx.static_prefix,
488+
&self.ctx.static_mount,
489+
self.ctx.static_dir_to_file.as_ref(),
490+
) {
483491
self.ctx
484492
.metrics
485493
.req_static_handled

0 commit comments

Comments
 (0)