kvarn_extensions/
php.rs

1use crate::*;
2
3/// Redirects all requests where `capture_fn` returns true to `connection`.
4///
5/// Consider using [`mount_php_with_working_directory`] for a simpler install. It allows you to
6/// easily add PHP for a path on your server, pointing to an arbitrary working directory.
7///
8/// Priority is `-8`.
9///
10/// A good `capture_fn` is `|req, _host| req.uri().path().ends_with(".php")`.
11///
12/// > Setting `capture_fn` to `|req, _host| req.uri().path() == "/sitemap.xml" || req.uri().path().ends_with(".php")` can be useful if
13/// > you're running WordPress.
14///
15/// If you set `path_rewrite`, keep in mind that the path given to you is percent decoded (%20 -> ' '), so if
16/// you're taking the path from the **request** URI, make sure to percent decode the path again.
17pub fn mount_php(
18    extensions: &mut Extensions,
19    connection: Connection,
20    capture_fn: impl Fn(&FatRequest, &Host) -> bool + Send + Sync + 'static,
21    path_rewrite: Option<
22        impl Fn(&str, &FatRequest, &Host) -> CompactString + Send + Sync + 'static,
23    >,
24) {
25    type DynPathRewrite =
26        Option<Box<dyn Fn(&str, &FatRequest, &Host) -> CompactString + Send + Sync + 'static>>;
27    let path_rewrite: DynPathRewrite = match path_rewrite {
28        Some(x) => Some(Box::new(x)),
29        None => None,
30    };
31    extensions.add_prepare_fn(
32        Box::new(move |req, host| !host.options.disable_fs && capture_fn(req, host)),
33        prepare!(
34            req,
35            host,
36            path,
37            addr,
38            move |connection: Connection, path_rewrite: DynPathRewrite| {
39                let rewriteen_path = path
40                    .and_then(Path::to_str)
41                    .into_iter()
42                    .zip(path_rewrite.iter())
43                    .map(|(path, path_rewrite)| path_rewrite(path, req, host))
44                    .next();
45                let path = rewriteen_path
46                    .or_else(|| path.and_then(Path::to_str).map(|s| s.to_compact_string()));
47                php(req, host, path.as_deref(), addr, connection.clone()).await
48            }
49        ),
50        extensions::Id::new(-8, "PHP").no_override(),
51    );
52}
53/// Redirects all requests that start with `capture` to `connection`, and sets the current
54/// directory for PHP to `working_directory`.
55///
56/// A request to `/cgi-bin/script.php` with `capture` set to `/cgi-bin/` and `working_directory`
57/// set to `/opt/cgi-bin/icelk/` executes `/opt/cgi-bin/icelk/script.php`.
58///
59/// # Errors
60///
61/// Returns an error if `working_directory` isn't found on the FS.
62pub async fn mount_php_with_working_directory(
63    extensions: &mut Extensions,
64    connection: Connection,
65    capture: impl Into<String>,
66    working_directory: impl Into<PathBuf>,
67) -> Result<(), io::Error> {
68    let working_directory = tokio::fs::canonicalize(working_directory.into()).await?;
69    let working_directory = working_directory.to_string_lossy().to_compact_string();
70    let capture = capture.into();
71    let rewrite_capture = capture.clone();
72    let file_capture = capture.clone();
73    let file_rewrite_capture = capture.clone();
74    let file_working_directory = working_directory.clone();
75    // add binding to just read file if it's not a .php file!
76    mount_php(
77        extensions,
78        connection,
79        move |req, _host| {
80            req.uri().path().starts_with(&capture) && req.uri().path().ends_with(".php")
81        },
82        Some(move |_path: &str, request: &FatRequest, _host: &Host| {
83            let path = format!(
84                "/{}",
85                request
86                    .uri()
87                    .path()
88                    .strip_prefix(&rewrite_capture)
89                    .expect("failed to strip a prefix we guaranteed the URI path starts with")
90            );
91            let decoded = percent_encoding::percent_decode_str(&path)
92                .decode_utf8()
93                .expect("percent decoding was successful earlier in Kvarn");
94            let p = utils::make_path(
95                &working_directory,
96                "",
97                // Ok, since Uri's have to start with a `/` (https://github.com/hyperium/http/issues/465).
98                // We also are OK with all Uris, since we did a check on the
99                // incoming and presume all internal extension changes are good.
100                utils::parse::uri(&decoded).unwrap(),
101                None,
102            );
103            p
104        }),
105    );
106    extensions.add_prepare_fn(
107        Box::new(move |req, host| {
108            !host.options.disable_fs
109                && req.uri().path().starts_with(&file_capture)
110                && !req.uri().path().ends_with(".php")
111        }),
112        prepare!(
113            req,
114            host,
115            _path,
116            _addr,
117            move |file_rewrite_capture: String, file_working_directory: CompactString| {
118                if req.method() != Method::GET && req.method() != Method::HEAD {
119                    return default_error_response(StatusCode::METHOD_NOT_ALLOWED, host, None)
120                        .await;
121                }
122                let path = format!(
123                    "/{}",
124                    req.uri()
125                        .path()
126                        .strip_prefix(file_rewrite_capture)
127                        .expect("failed to strip a prefix we guaranteed the URI path starts with")
128                );
129                let decoded = percent_encoding::percent_decode_str(&path)
130                    .decode_utf8()
131                    .expect("percent decoding was successful earlier in Kvarn");
132                let p = utils::make_path(
133                    file_working_directory,
134                    "",
135                    // Ok, since Uri's have to start with a `/` (https://github.com/hyperium/http/issues/465).
136                    // We also are OK with all Uris, since we did a check on the
137                    // incoming and presume all internal extension changes are good.
138                    utils::parse::uri(&decoded).unwrap(),
139                    None,
140                );
141                let file = read_file_cached(&p, host.file_cache.as_ref()).await;
142                if let Some(file) = file {
143                    FatResponse::new(Response::new(file), comprash::ServerCachePreference::Full)
144                } else {
145                    default_error_response(StatusCode::NOT_FOUND, host, None).await
146                }
147            }
148        ),
149        extensions::Id::new(-9, "PHP file server").no_override(),
150    );
151    Ok(())
152}
153fn php<'a>(
154    req: &'a mut FatRequest,
155    host: &'a Host,
156    path: Option<&'a str>,
157    address: SocketAddr,
158    connection: Connection,
159) -> RetFut<'a, FatResponse> {
160    box_fut!({
161        // This will be `Some`.
162        // The only reason a path isn't `Some` is if the `disable_fs` flag is set in `host::Options`,
163        // which we check for in the `If` predicate above.
164        if let Some(path) = path {
165            if tokio::fs::metadata(&path).await.is_err() {
166                return default_error_response(StatusCode::NOT_FOUND, host, None).await;
167            }
168
169            let body = match req.body_mut().read_to_bytes(1024 * 1024 * 16).await {
170                Ok(body) => body,
171                Err(_) => {
172                    return FatResponse::cache(
173                        default_error(
174                            StatusCode::BAD_REQUEST,
175                            Some(host),
176                            Some("failed to read body".as_bytes()),
177                        )
178                        .await,
179                    )
180                }
181            };
182            let output =
183                match fastcgi::from_prepare(req, &body, Path::new(path), address, connection).await
184                {
185                    Ok(vec) => vec,
186                    Err(err) => {
187                        error!("FastCGI failed. {}", err);
188                        return default_error_response(
189                            StatusCode::INTERNAL_SERVER_ERROR,
190                            host,
191                            None,
192                        )
193                        .await;
194                    }
195                };
196            let output = Bytes::copy_from_slice(&output);
197            match async_bits::read::response_php(&output) {
198                Ok(response) => FatResponse::cache(response),
199                Err(err) => {
200                    error!("failed to parse response; {}", err.as_str());
201                    default_error_response(StatusCode::NOT_FOUND, host, None).await
202                }
203            }
204        } else {
205            error!("Path is none. This is a internal contract error.");
206            default_error_response(StatusCode::INTERNAL_SERVER_ERROR, host, None).await
207        }
208    })
209}
210
211#[cfg(test)]
212mod tests {
213    use kvarn_testing::prelude::*;
214
215    #[tokio::test]
216    async fn no_fs() {
217        let server = ServerBuilder::from(crate::new())
218            .with_options(|options| {
219                options.disable_fs();
220            })
221            .run()
222            .await;
223
224        let response = server.get("index.php").send().await.unwrap();
225        assert_eq!(response.status().as_str(), StatusCode::NOT_FOUND.as_str());
226    }
227}