Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ async-compression = { version = "0.4", features = ["futures-io", "gzip"] }
deno_graph = "=0.110.2"
deno_ast = { version = "0.53.0", features = ["view"] }
# sync with frontend/deno.json
deno_doc = { version = "=0.204.0", features = ["comrak"] }
deno_doc = { version = "=0.205.0", features = ["comrak"] }
deno_error = "0.7.0"
comrak = { version = "0.29.0", default-features = false }
ammonia = "4.0.0"
Expand Down Expand Up @@ -129,6 +129,9 @@ tree-sitter-rust = "0.21.2"
tree-sitter-html = "0.20.3"
tree-sitter-bash = "0.21.0"
tree-sitter-xml = "0.6.4"
tree-sitter-toml-ng = "0.6.0"
tree-sitter-yaml = "0.6.1"
tree-sitter-c = "0.21.4"
lazy_static = "1.5.0"
tikv-jemallocator = { version = "0.6", features = ["profiling"] }
tikv-jemalloc-ctl = { version = "0.6", features = ["stats"] }
Expand Down
4 changes: 4 additions & 0 deletions api/src/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3556,6 +3556,10 @@ components:
type: string
nullable: true
description: HTML-rendered source code view. Null for binary files.
rendered:
type: string
nullable: true
description: Rendered HTML, present for markdown files.
required:
- kind
- size
Expand Down
29 changes: 29 additions & 0 deletions api/src/api/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,35 @@ mod integration {
);
}

#[tokio::test]
async fn staff_can_reply_to_a_ticket_nobody_has_claimed() {
let mut t = TestSetup::new().await;

deliver(&mut t, inbound("<a@example.com>", "Help", json!([]))).await;
let ticket = all_tickets(&mut t).await.remove(0);
assert_eq!(ticket.status, TicketStatus::Open);

// The ticket has no creator until it is claimed, so anything comparing the
// author against it has to cope with a NULL on the other side. This is the
// ordinary path for answering support mail, and it used to 500.
let staff_token = t.staff_user.token.clone();
let mut resp = t
.http()
.post(format!("/api/tickets/{}", ticket.id))
.token(Some(&staff_token))
.body_json(json!({ "message": "Have you tried publishing again?" }))
.call()
.await
.unwrap();
let reply: crate::api::ApiTicketMessage = resp.expect_ok().await;
assert_eq!(reply.message, "Have you tried publishing again?");
assert_eq!(reply.direction, crate::db::TicketMessageDirection::Outbound);

// Staff spoke last, so the reporter is the one now owed a response.
let after = all_tickets(&mut t).await.remove(0);
assert_eq!(after.status, TicketStatus::WaitingOnUser);
}

#[tokio::test]
async fn wrong_webhook_password_is_rejected() {
let mut t = TestSetup::new().await;
Expand Down
76 changes: 70 additions & 6 deletions api/src/api/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1738,7 +1738,7 @@ pub async fn get_source_handler(

let db = req.data::<Database>().unwrap();
let buckets = req.data::<Buckets>().unwrap();
let _ = db
let (_, repo, _) = db
.get_package(&scope, &package)
.await?
.ok_or(ApiError::PackageNotFound)?;
Expand Down Expand Up @@ -1822,7 +1822,31 @@ pub async fn get_source_handler(
None
};

ApiSource::File { size, view }
// Markdown files additionally get a rendered form so the frontend can
// offer a GitHub-style preview next to the source.
let rendered = if path_buf
.extension()
.is_some_and(|ext| matches!(&*ext.to_string_lossy(), "md" | "markdown"))
{
std::str::from_utf8(&file).ok().and_then(|md| {
crate::docs::render_markdown_file(
md,
&scope,
&package,
&version.version,
repo,
path_buf.to_string_lossy().as_ref(),
)
})
} else {
None
};

ApiSource::File {
size,
view,
rendered,
}
} else {
let files = db
.list_package_files(&scope, &package, &version.version)
Expand Down Expand Up @@ -4219,7 +4243,8 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg==
let task = process_tarball_setup(&t, create_mock_tarball("ok")).await;
assert_eq!(task.status, PublishingTaskStatus::Success, "{:?}", task);

// index page
// index page (pinned version): usage snippets install exactly this
// version, and the jsr specifier is pinned to it
let mut resp = t
.http()
.get("/api/scopes/scope/packages/foo/versions/1.2.3/docs")
Expand All @@ -4233,11 +4258,38 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg==
comrak_css: _,
script: _,
breadcrumbs,
toc: _,
toc,
main: _,
} => {
assert_eq!(version.version, task.package_version);
assert!(breadcrumbs.is_none(), "{:?}", breadcrumbs);
let toc_json = serde_json::to_string(&toc).unwrap();
assert!(
toc_json.contains("deno add jsr:@scope/foo@1.2.3"),
"{toc_json}"
);
assert!(toc_json.contains("jsr:@scope/foo@1.2.3"), "{toc_json}");
}
ApiPackageVersionDocs::Redirect { .. } => panic!(),
}

// index page (latest): add commands are unversioned, but the direct jsr
// specifier carries a caret constraint on the latest version
let mut resp = t
.http()
.get("/api/scopes/scope/packages/foo/versions/latest/docs")
.call()
.await
.unwrap();
let docs: ApiPackageVersionDocs = resp.expect_ok().await;
match docs {
ApiPackageVersionDocs::Content { toc, .. } => {
let toc_json = serde_json::to_string(&toc).unwrap();
assert!(
!toc_json.contains("deno add jsr:@scope/foo@1.2.3"),
"{toc_json}"
);
assert!(toc_json.contains("jsr:@scope/foo@^1.2.3"), "{toc_json}");
}
ApiPackageVersionDocs::Redirect { .. } => panic!(),
}
Expand Down Expand Up @@ -5148,12 +5200,18 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg==
let mut resp = t.http().get(url).call().await.unwrap();
let body = resp.expect_ok::<ApiPackageVersionSource>().await;

let ApiSource::File { size, view } = body.source else {
let ApiSource::File {
size,
view,
rendered,
} = body.source
else {
panic!();
};

assert_eq!(size, 124);
assert!(view.is_some());
assert!(rendered.is_none());

let url = format!(
"/api/scopes/{}/packages/{}/versions/{}/source?path=/bin.bin",
Expand All @@ -5162,12 +5220,18 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg==
let mut resp = t.http().get(url).call().await.unwrap();
let body = resp.expect_ok::<ApiPackageVersionSource>().await;

let ApiSource::File { size, view } = body.source else {
let ApiSource::File {
size,
view,
rendered,
} = body.source
else {
panic!();
};

assert_eq!(size, 1000);
assert!(view.is_none());
assert!(rendered.is_none());
}

#[tokio::test]
Expand Down
11 changes: 9 additions & 2 deletions api/src/api/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -726,8 +726,15 @@ pub struct ApiSourceDirEntry {
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum ApiSource {
Dir { entries: Vec<ApiSourceDirEntry> },
File { size: usize, view: Option<String> },
Dir {
entries: Vec<ApiSourceDirEntry>,
},
File {
size: usize,
view: Option<String>,
/// Rendered HTML, present for markdown files.
rendered: Option<String>,
},
}

#[derive(Debug, Serialize, Deserialize)]
Expand Down
5 changes: 4 additions & 1 deletion api/src/db/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4765,8 +4765,11 @@ gitlab_id: r.user_gitlab_id,
) -> Result<(TicketMessage, UserPublic)> {
let mut tx = self.pool.begin().await?;

// COALESCE, because a ticket opened by email has no creator until somebody
// claims it, and `NULL = $2` is NULL rather than false. Without it, every
// staff reply to an unclaimed ticket fails to decode and 500s.
let author_is_creator = sqlx::query_scalar!(
r#"SELECT creator = $2 as "is_creator!" FROM tickets WHERE id = $1"#,
r#"SELECT COALESCE(creator = $2, false) as "is_creator!" FROM tickets WHERE id = $1"#,
id as _,
author as _,
)
Expand Down
Loading
Loading