michal/tit
Browse tree · Show commit · Download archive
Diff
511290b3ecd4 → 0007deff399f
assets/style.css
Mode 100644 → 100644; object 5830091e4871 → 95b436f4dc02
@@ -179,6 +179,22 @@
border-bottom: 1px solid GrayText;
}
+.key-list {
+ padding: 0;
+ list-style: none;
+}
+
+.key-card {
+ margin-block: 1rem;
+ padding: 1rem;
+ border: 1px solid GrayText;
+ border-radius: 0.25rem;
+}
+
+.key-card > h3 {
+ margin-top: 0;
+}
+
.two-column {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
src/http/mod.rs
Mode 100644 → 100644; object 6e67ff28a89e → 4ebbd9107636
@@ -16,7 +16,7 @@
use axum::Router;
use axum::body::{Body, Bytes, HttpBody};
use axum::extract::{
- ConnectInfo, DefaultBodyLimit, Extension, OriginalUri, Path, Query, RawQuery, Request, State,
+ ConnectInfo, DefaultBodyLimit, Extension, OriginalUri, Path, Query, Request, State,
};
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header};
use axum::middleware::{self, Next};
@@ -30,7 +30,6 @@
use crate::account::{AccountError, AccountKeyRequest, AccountService};
use crate::auth::validate_username;
-use crate::domain::repository::validate_slug;
use crate::feed_token::FeedTokenService;
use crate::issue::IssueService;
use crate::maintenance::MaintenanceGate;
@@ -46,7 +45,6 @@
use self::public::PublicWeb;
const STYLE: &str = include_str!("../../assets/style.css");
-const MAX_LOCATION_QUERY_BYTES: usize = 512;
const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
const MAX_BLOCKING_WEB_JOBS: usize = 8;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
@@ -390,7 +388,6 @@
.route("/", get(home))
.route("/healthz", get(health))
.route("/metrics", get(metrics))
- .route("/go", get(go_to_repository))
.route(
"/signup",
get(signup_form)
@@ -596,9 +593,6 @@
StatusCode::OK,
&request_id.0,
HomePage {
- owner: "",
- repository: "",
- error: "",
signed_in,
username: &username,
csrf: &csrf,
@@ -621,9 +615,6 @@
StatusCode::OK,
&request_id.0,
HomePage {
- owner: "",
- repository: "",
- error: "",
signed_in,
username: &username,
csrf: &csrf,
@@ -656,38 +647,6 @@
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(body))
.expect("the readiness response is valid")
-}
-
-async fn go_to_repository(
- Extension(request_id): Extension<RequestId>,
- Extension(actor): Extension<RequestActor>,
- RawQuery(query): RawQuery,
-) -> Response {
- match parse_location_query(query.as_deref()) {
- Ok((owner, repository)) => {
- let location = format!("/{owner}/{repository}");
- Response::builder()
- .status(StatusCode::FOUND)
- .header(header::LOCATION, location)
- .header(header::CACHE_CONTROL, "no-store")
- .body(Body::empty())
- .expect("the repository redirect is valid")
- }
- Err(LocationQueryError { owner, repository }) => render_home(
- StatusCode::BAD_REQUEST,
- &request_id.0,
- HomePage {
- owner: &owner,
- repository: &repository,
- error: "Enter a valid lowercase owner and repository.",
- signed_in: actor.0.is_some(),
- username: actor.0.as_deref().unwrap_or_default(),
- csrf: "",
- repositories: &[],
- recent_repositories: &[],
- },
- ),
- }
}
async fn signup_form(
@@ -2271,10 +2230,6 @@
status,
&HomeTemplate {
request_id,
- owner: page.owner,
- repository: page.repository,
- error: page.error,
- has_error: !page.error.is_empty(),
signed_in: page.signed_in,
username: page.username,
csrf: page.csrf,
@@ -2307,9 +2262,6 @@
}
struct HomePage<'a> {
- owner: &'a str,
- repository: &'a str,
- error: &'a str,
signed_in: bool,
username: &'a str,
csrf: &'a str,
@@ -2356,28 +2308,6 @@
}
}
-fn parse_location_query(query: Option<&str>) -> Result<(String, String), LocationQueryError> {
- let query = query.ok_or_else(LocationQueryError::default)?;
- if query.len() > MAX_LOCATION_QUERY_BYTES || !valid_percent_encoding(query.as_bytes()) {
- return Err(LocationQueryError::default());
- }
- let mut owner = None;
- let mut repository = None;
- for (name, value) in url::form_urlencoded::parse(query.as_bytes()) {
- match name.as_ref() {
- "owner" if owner.is_none() => owner = Some(value.into_owned()),
- "repository" if repository.is_none() => repository = Some(value.into_owned()),
- _ => return Err(LocationQueryError::default()),
- }
- }
- let owner = owner.unwrap_or_default();
- let repository = repository.unwrap_or_default();
- if validate_username(&owner).is_err() || validate_slug(&repository).is_err() {
- return Err(LocationQueryError { owner, repository });
- }
- Ok((owner, repository))
-}
-
fn valid_percent_encoding(input: &[u8]) -> bool {
let mut index = 0;
while index < input.len() {
@@ -2399,12 +2329,6 @@
#[derive(Clone)]
struct RequestId(String);
-#[derive(Default)]
-struct LocationQueryError {
- owner: String,
- repository: String,
-}
-
#[derive(Default, serde::Deserialize)]
struct ProfileQuery {
page: Option<usize>,
@@ -2414,10 +2338,6 @@
#[template(path = "home.html")]
struct HomeTemplate<'a> {
request_id: &'a str,
- owner: &'a str,
- repository: &'a str,
- error: &'a str,
- has_error: bool,
signed_in: bool,
username: &'a str,
csrf: &'a str,
src/http/public.rs
Mode 100644 → 100644; object 235f0a42eeb1 → 8b99179356ac
@@ -1479,7 +1479,11 @@
if let Ok(content) = std::str::from_utf8(&data)
&& !data.contains(&0)
{
- page.readme_html = markdown::render(content);
+ let base = format!(
+ "/{}/{}/blob/{}/",
+ page.owner, page.repository, page.commit_id
+ );
+ page.readme_html = markdown::render_repository(content, &base);
} else {
page.readme_binary = true;
}
src/markdown.rs
Mode 100644 → 100644; object 51625fb7772c → 80b052d6a657
@@ -19,14 +19,45 @@
}
pub fn render(source: &str) -> RenderedMarkdown {
+ render_with_base(source, None)
+}
+
+pub fn render_repository(source: &str, base: &str) -> RenderedMarkdown {
+ render_with_base(source, Some(base))
+}
+
+fn render_with_base(source: &str, base: Option<&str>) -> RenderedMarkdown {
if source.len() > MAX_MARKDOWN_BYTES {
return RenderedMarkdown(LIMIT_MESSAGE.to_owned());
}
let mut skipped_link = false;
let events = Parser::new(source).filter_map(|event| match event {
- Event::Start(Tag::Link { ref dest_url, .. }) if !safe_link(dest_url) => {
- skipped_link = true;
- None
+ Event::Start(Tag::Link {
+ link_type,
+ dest_url,
+ title,
+ id,
+ }) => {
+ if !safe_link(&dest_url) {
+ skipped_link = true;
+ return None;
+ }
+ let destination = match base {
+ Some(base) => resolve_repository_link(&dest_url, base),
+ None => Some(dest_url.to_string()),
+ };
+ match destination {
+ Some(destination) => Some(Event::Start(Tag::Link {
+ link_type,
+ dest_url: destination.into(),
+ title,
+ id,
+ })),
+ None => {
+ skipped_link = true;
+ None
+ }
+ }
}
Event::End(TagEnd::Link) if skipped_link => {
skipped_link = false;
@@ -98,9 +129,37 @@
}
}
+fn resolve_repository_link(destination: &str, base: &str) -> Option<String> {
+ if Url::parse(destination).is_ok()
+ || destination.starts_with('/')
+ || destination.starts_with('#')
+ || destination.starts_with('?')
+ {
+ return Some(destination.to_owned());
+ }
+
+ let origin = Url::parse("https://tit.invalid/").expect("the Markdown origin is valid");
+ let base = origin.join(base).ok()?;
+ let resolved = base.join(destination).ok()?;
+ if !resolved.path().starts_with(base.path()) {
+ return None;
+ }
+
+ let mut link = resolved.path().to_owned();
+ if let Some(query) = resolved.query() {
+ link.push('?');
+ link.push_str(query);
+ }
+ if let Some(fragment) = resolved.fragment() {
+ link.push('#');
+ link.push_str(fragment);
+ }
+ Some(link)
+}
+
#[cfg(test)]
mod tests {
- use super::render;
+ use super::{render, render_repository};
#[test]
fn renders_the_documented_subset() {
@@ -147,6 +206,22 @@
assert!(output.contains("Text & markup."));
assert!(!output.contains("href="));
+ }
+
+ #[test]
+ fn resolves_repository_links_inside_the_current_revision() {
+ let output = render_repository(
+ "[license](LICENSE) [guide](docs/guide.md#start) [anchor](#usage) \
+ [site](/help) [outside](../secret)",
+ "/alice/example/blob/abc123/",
+ )
+ .to_string();
+
+ assert!(output.contains("href=\"/alice/example/blob/abc123/LICENSE\""));
+ assert!(output.contains("href=\"/alice/example/blob/abc123/docs/guide.md#start\""));
+ assert!(output.contains("href=\"#usage\""));
+ assert!(output.contains("href=\"/help\""));
+ assert!(!output.contains("href=\"../secret\""));
}
#[test]
templates/account-page.html
Mode 100644 → 100644; object b911571fa1cf → 52279b8a8c76
@@ -11,7 +11,6 @@
<div class="two-column">
<section>
<h2>Public profile</h2>
- <p><a href="/{{ username }}">View your public profile</a></p>
<form action="/account/profile" method="post">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div class="field">
@@ -24,6 +23,7 @@
</div>
<button type="submit">Save profile</button>
</form>
+ <p><a href="/{{ username }}">View your public profile</a></p>
</section>
<section>
<h2>Create repository</h2>
@@ -39,50 +39,35 @@
<p><a href="/feeds">Manage private feed tokens</a></p>
<h2>Sessions</h2>
<p><a href="/logout">Log out all sessions</a></p>
- </section>
- </div>
- <section>
<h2>SSH keys</h2>
- <div class="table-scroll">
- <table>
- <thead>
- <tr>
- <th>Label</th>
- <th>Fingerprint</th>
- <th>Created</th>
- <th>Last used</th>
- <th>State</th>
- <th>Action</th>
- </tr>
- </thead>
- <tbody>
+ <ul class="key-list">
{% for key in keys %}
- <tr>
- <td>{{ key.label }}</td>
- <td><code>{{ key.fingerprint }}</code></td>
- <td>{{ key.created_at }}</td>
- <td>{{ key.last_used_at }}</td>
- <td>{% if key.active %}Active{% else %}Revoked{% endif %}</td>
- <td>
+ <li class="key-card">
+ <h3>{{ key.label }}</h3>
+ <dl>
+ <dt>Fingerprint</dt>
+ <dd><code>{{ key.fingerprint }}</code></dd>
+ <dt>Created</dt>
+ <dd>{{ key.created_at }}</dd>
+ <dt>Last used</dt>
+ <dd>{{ key.last_used_at }}</dd>
+ <dt>State</dt>
+ <dd>{% if key.active %}Active{% else %}Revoked{% endif %}</dd>
+ </dl>
{% if key.active %}
{% if active_key_count > 1 %}
- <form action="/account/keys/revoke" method="post">
- <input type="hidden" name="csrf" value="{{ csrf }}">
- <input type="hidden" name="fingerprint" value="{{ key.fingerprint }}">
- <button type="submit">Revoke</button>
- </form>
+ <form action="/account/keys/revoke" method="post">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <input type="hidden" name="fingerprint" value="{{ key.fingerprint }}">
+ <button type="submit">Revoke</button>
+ </form>
{% else %}
- Final active key
+ <p>This is your final active key.</p>
{% endif %}
- {% else %}
- —
{% endif %}
- </td>
- </tr>
+ </li>
{% endfor %}
- </tbody>
- </table>
- </div>
+ </ul>
<h3>Add SSH key</h3>
<form action="/account/keys/add" method="post">
<input type="hidden" name="csrf" value="{{ csrf }}">
@@ -97,4 +82,5 @@
<button type="submit">Add key</button>
</form>
</section>
+ </div>
{% endblock %}
templates/home.html
Mode 100644 → 100644; object 6805c686f933 → d01825827a28
@@ -1,9 +1,9 @@
{% extends "base.html" %}
{% block title %}{% if signed_in %}{{ username }}{% else %}Repositories{% endif %} · tit{% endblock %}
{% block content %}
+{% if signed_in %}
<div class="two-column">
<section>
-{% if signed_in %}
<h1>{{ username }}</h1>
<p><a href="/account">Open your account profile</a>.</p>
<h2>Your repositories</h2>
@@ -27,8 +27,11 @@
{% endfor %}
</ul>
{% endif %}
+ </section>
+ <section>
<h2>Recently updated public repositories</h2>
{% else %}
+ <section>
<h1>Recently updated public repositories</h1>
{% endif %}
{% if recent_repositories.is_empty() %}
@@ -45,23 +48,7 @@
</ul>
{% endif %}
</section>
- <section>
- <h2>Open a repository</h2>
- <p>Enter an owner and a repository to open its public page.</p>
-{% if has_error %}
- <p class="error" role="alert">{{ error }}</p>
-{% endif %}
- <form action="/go" method="get">
- <div class="field">
- <label for="owner">Owner</label>
- <input id="owner" name="owner" value="{{ owner }}" required autocomplete="off" autocapitalize="none" spellcheck="false">
- </div>
- <div class="field">
- <label for="repository">Repository</label>
- <input id="repository" name="repository" value="{{ repository }}" required autocomplete="off" autocapitalize="none" spellcheck="false">
- </div>
- <button type="submit">Open repository</button>
- </form>
- </section>
+{% if signed_in %}
</div>
+{% endif %}
{% endblock %}
tests/public_routes.rs
Mode 100644 → 100644; object c622c042a185 → 519c6052128a
@@ -119,7 +119,14 @@
assert!(summary_text.contains("<h1>tit fixture</h1>"));
assert!(summary_text.contains("<strong>safe</strong>"));
assert!(summary_text.contains("<code><safe></code>"));
- assert!(summary_text.contains("href=\"docs/guide.md\""));
+ assert!(summary_text.contains(&format!(
+ "href=\"/alice/example/blob/{}/docs/guide.md\"",
+ fixture.head
+ )));
+ assert!(summary_text.contains(&format!(
+ "href=\"/alice/example/blob/{}/LICENSE\"",
+ fixture.head
+ )));
assert!(!summary_text.contains("<script"));
assert!(!summary_text.contains("javascript:"));
assert!(!summary_text.contains("<img"));
@@ -1636,9 +1643,10 @@
]));
fs::write(
worktree.join("README.md"),
- b"# tit fixture\n\n**safe** and `<safe>`\n\n[guide](docs/guide.md) [bad](javascript:alert(1))\n\n\n\n<script>alert(2)</script>\n",
+ b"# tit fixture\n\n**safe** and `<safe>`\n\n[guide](docs/guide.md) [license](LICENSE) [bad](javascript:alert(1))\n\n\n\n<script>alert(2)</script>\n",
)
.expect("write the README");
+ fs::write(worktree.join("LICENSE"), b"fixture license\n").expect("write the license");
fs::create_dir(worktree.join("nested")).expect("create a nested directory");
fs::write(worktree.join("nested/file.txt"), b"first line\n").expect("write the text file");
fs::write(worktree.join("binary.dat"), b"binary\0content").expect("write the binary file");
tests/serve.rs
Mode 100644 → 100644; object 74ed3604d91d → be7169da10a4
@@ -220,6 +220,8 @@
assert!(!account.contains("<a href=\"/recover\">Recover account</a>"));
assert!(account.contains("action=\"/account/repositories\""));
assert!(!account.contains("object-format"));
+ assert!(account.contains("<ul class=\"key-list\">"));
+ assert!(!account.contains("<table>"));
for path in ["/login", "/signup", "/recover"] {
let response = http_get_with_headers(http, path, &[("Cookie", &cookies)]);
assert!(response.starts_with("HTTP/1.1 303"));
@@ -229,6 +231,10 @@
assert!(signed_in_home.contains("<h1>alice</h1>"));
assert!(signed_in_home.contains("<h2>Your repositories</h2>"));
assert!(signed_in_home.contains("<h2>Recently updated public repositories</h2>"));
+ assert!(
+ signed_in_home
+ .contains("</section>\n <section>\n <h2>Recently updated public repositories</h2>")
+ );
assert_eq!(signed_in_home.matches(">alice/example</a>").count(), 2);
assert!(signed_in_home.contains("<a href=\"/account\">Account</a>"));
assert!(signed_in_home.contains("<a href=\"/logout\">Log out</a>"));
tests/snapshots/web/bad-request.html
Mode 100644 → ; object 99703e680414 →
@@ -1,59 +1,0 @@ -<!doctype html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>Repositories · tit</title> - <link rel="stylesheet" href="/assets/style.css?v=2"> -</head> -<body> - <a class="skip-link" href="#main">Skip to content</a> - <header class="site-header"> - <a class="brand" href="/" aria-label="tit home">tit</a> - <nav aria-label="Primary"> - <a href="/">Home</a> - <a href="/search">Search</a> - - <a href="/signup">Create account</a> - <a href="/recover">Recover account</a> - <a href="/login">Log in</a> - - </nav> - </header> - <main id="main"> - - <div class="two-column"> - <section> - - <h1>Recently updated public repositories</h1> - - - <p>No repositories are available.</p> - - </section> - <section> - <h2>Open a repository</h2> - <p>Enter an owner and a repository to open its public page.</p> - - <p class="error" role="alert">Enter a valid lowercase owner and repository.</p> - - <form action="/go" method="get"> - <div class="field"> - <label for="owner">Owner</label> - <input id="owner" name="owner" value="<script>" required autocomplete="off" autocapitalize="none" spellcheck="false"> - </div> - <div class="field"> - <label for="repository">Repository</label> - <input id="repository" name="repository" value="example" required autocomplete="off" autocapitalize="none" spellcheck="false"> - </div> - <button type="submit">Open repository</button> - </form> - </section> - </div> - - </main> - <footer> - <small>Request <code><request-id></code></small> - </footer> -</body> -</html>
tests/snapshots/web/home.html
Mode 100644 → 100644; object 856a3d36f189 → 166bf0008710
@@ -22,32 +22,17 @@ </header> <main id="main"> - <div class="two-column"> - <section> + <section> <h1>Recently updated public repositories</h1> + <p>No repositories are available.</p> - </section> - <section> - <h2>Open a repository</h2> - <p>Enter an owner and a repository to open its public page.</p> - <form action="/go" method="get"> - <div class="field"> - <label for="owner">Owner</label> - <input id="owner" name="owner" value="" required autocomplete="off" autocapitalize="none" spellcheck="false"> - </div> - <div class="field"> - <label for="repository">Repository</label> - <input id="repository" name="repository" value="" required autocomplete="off" autocapitalize="none" spellcheck="false"> - </div> - <button type="submit">Open repository</button> - </form> </section> - </div> + </main> <footer>
tests/web_shell.rs
Mode 100644 → 100644; object 1f4f6a7836ff → 47eb9e815447
@@ -85,9 +85,8 @@
assert!(home.body.contains("<nav aria-label=\"Primary\">"));
assert!(home.body.contains("<main id=\"main\">"));
assert!(home.body.contains("<footer>"));
- assert!(home.body.contains("<form action=\"/go\" method=\"get\">"));
- assert!(home.body.contains("name=\"owner\""));
- assert!(home.body.contains("name=\"repository\""));
+ assert!(!home.body.contains("Open a repository"));
+ assert!(!home.body.contains("<form action=\"/go\""));
assert!(!home.body.to_ascii_lowercase().contains("<script"));
assert_security_policy(&home);
assert_snapshot(&home, include_str!("snapshots/web/home.html"));
@@ -95,6 +94,15 @@
let request_id = home.header("x-request-id");
assert_request_id(request_id);
assert!(home.body.contains(&format!("<code>{request_id}</code>")));
+
+ let removed_repository_form = request(
+ server.address(),
+ "GET",
+ "/go?owner=alice&repository=example",
+ &[],
+ );
+ assert_eq!(removed_repository_form.status, 404);
+ assert_security_policy(&removed_repository_form);
let head = request(server.address(), "HEAD", "/", &[]);
assert_eq!(head.status, 200);
@@ -139,62 +147,6 @@
let wrong_signup_method = request(server.address(), "PUT", "/signup", &[]);
assert_eq!(wrong_signup_method.status, 405);
assert_eq!(wrong_signup_method.header("allow"), "GET, HEAD, POST");
-
- server.shutdown().await.expect("stop the Web server");
-}
-
-#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
-async fn submits_the_repository_form_with_plain_http() {
- let server = start().await;
-
- let redirect = request(
- server.address(),
- "GET",
- "/go?owner=alice&repository=example",
- &[],
- );
- assert_eq!(redirect.status, 302);
- assert_eq!(redirect.header("location"), "/alice/example");
- assert_eq!(redirect.header("cache-control"), "no-store");
- assert!(redirect.body.is_empty());
- assert_security_policy(&redirect);
-
- for path in [
- "/go",
- "/go?owner=Alice&repository=example",
- "/go?owner=alice&repository=../example",
- "/go?owner=alice&owner=bob&repository=example",
- "/go?owner=alice&repository=example&extra=value",
- "/go?owner=alice&repository=%",
- ] {
- let response = request(server.address(), "GET", path, &[]);
- assert_eq!(response.status, 400, "unexpected status for {path}");
- assert!(response.body.contains("role=\"alert\""));
- assert!(
- response
- .body
- .contains("Enter a valid lowercase owner and repository.")
- );
- assert_security_policy(&response);
- }
-
- let injection = request(
- server.address(),
- "GET",
- "/go?owner=%3Cscript%3E&repository=example",
- &[],
- );
- assert_eq!(injection.status, 400);
- assert!(injection.body.contains("value=\"<script>\""));
- assert!(!injection.body.contains("value=\"<script>\""));
- assert!(!injection.body.to_ascii_lowercase().contains("<script"));
- assert_snapshot(&injection, include_str!("snapshots/web/bad-request.html"));
-
- let oversized = format!("/go?owner={}&repository=example", "a".repeat(512));
- assert_eq!(
- request(server.address(), "GET", &oversized, &[]).status,
- 400
- );
server.shutdown().await.expect("stop the Web server");
}