An agent can put an image in a pull request, an issue or a comment without committing it: on flagon-io/session-lab #2 a cloud session asked to show a screenshot of its preview in the pull request's description found no way to upload one (not in g1t --help, g1t api --list or the docs), so it committed the PNG, linked it at that commit and removed it in a second commit. The site has no comment-image upload either; images only ever came from repository files. Now upload_attachment (POST /repos/{owner}/{repo}/attachments, issues:write, the issue tool's attach action over MCP) takes name, data (the file base64-encoded, as upload_sarif takes its file) and an optional content_type, and answers in snake_case with id, name, url, markdown (, ready to paste), content_type and size. The work service decides, as add_comment does: a confirmed account that can read the repository, which is not archived. The type is read from the bytes (PNG, JPEG, GIF or WebP; a content_type that says otherwise is refused), at most 10 MB; too large is the new FailureCode too_large (413), another type the new unsupported_type (415), in Rust and TS contracts and the OpenAPI error codes. The bytes go in the usercontent namespace (AVATARS, now bound to work) under attachments/<id>, where the id is 256 random bits so the address is its permission, as Docs files' is; a row in the new attachments table (migration 0048) names the repository, and repo.purged deletes the bytes, then the rows. The usercontent origin serves /attachments/<id> as it serves avatars (stored type, nosniff, sandboxed). The API forms url from USERCONTENT_URL as the site does (g1tusercontent.com for g1t.sh, SITE_URL/-/usercontent elsewhere; the self-host config now passes it to the API). The CLI has g1t attach FILE... (prints each image's Markdown; --json the fields, a list for several) and --attach FILE (repeatable) on g1t issue create, issue comment, pr create, pr comment and pr ready, which uploads and appends the Markdown after the text. A session's run credential gets upload_attachment through issues:write, bound to its own repository (tested), and the cloud session's system prompt says to upload screenshots with g1t attach and never commit images just to link them. Docs: the CLI guide (Images in descriptions and comments), pull requests (Images), sessions, the MCP reference, self-hosting's USERCONTENT_URL row, reference.json and the regenerated openapi.json, CLI ops table and CLI reference. Deploy order: apply work migration 0048 and deploy work (new AVATARS binding), then the site (usercontent serves /attachments), then api, then integrations (prompt) and the docs; g1t attach and --attach ship with the next CLI release, which cloud sessions need before the prompt's advice works. Checked: cargo test -p g1t-contracts (319), -p g1t-work (209), -p g1t-api (211; openapi regenerated with G1T_WRITE_OPENAPI), -p g1t (112 + 51; ops_table and cli_reference regenerated with G1T_WRITE_OPS and G1T_WRITE_DOCS), -p g1t-integrations (76); cargo check -p g1t-api, -p g1t-work and -p g1t-integrations --target wasm32-unknown-unknown; node --test apps/web/app/lib/token-scopes.test.ts.
- G1t-Session:
- ses_01m4z6ztqreyntc2ns37hjfg8m
36 files+387−1620 files not counted0/36 viewed
| 13 | 13 | pub const HOSTED_SITE: &str = "https://g1t.sh"; | |
| 14 | 14 | pub const HOSTED_API: &str = "https://api.g1t.sh"; | |
| 15 | 15 | pub const HOSTED_MCP: &str = "https://mcp.g1t.sh"; | |
| 16 | + | pub const HOSTED_USERCONTENT: &str = "https://g1tusercontent.com"; | |
| 16 | 17 | ||
| 17 | 18 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 18 | 19 | pub struct Addresses { | |
| ⋯ | |||
| 22 | 23 | pub api: String, | |
| 23 | 24 | /// MCP_URL: the MCP server, and the OAuth protected resource. | |
| 24 | 25 | pub mcp: String, | |
| 26 | + | /// USERCONTENT_URL: where attachments are served (apps/web/workers/usercontent.ts), | |
| 27 | + | /// as the site reads it (apps/web/app/lib/addresses.ts): unset, g1tusercontent.com | |
| 28 | + | /// for g1t.sh, and a path on the site (`SITE_URL/-/usercontent`) for any other. | |
| 29 | + | pub usercontent: String, | |
| 25 | 30 | } | |
| 26 | 31 | ||
| 27 | 32 | impl Default for Addresses { | |
| ⋯ | |||
| 30 | 35 | site: HOSTED_SITE.to_owned(), | |
| 31 | 36 | api: HOSTED_API.to_owned(), | |
| 32 | 37 | mcp: HOSTED_MCP.to_owned(), | |
| 38 | + | usercontent: HOSTED_USERCONTENT.to_owned(), | |
| 33 | 39 | } | |
| 34 | 40 | } | |
| 35 | 41 | } | |
| ⋯ | |||
| 42 | 48 | ||
| 43 | 49 | impl Addresses { | |
| 44 | 50 | pub fn from_settings(site: Option<String>, api: Option<String>, mcp: Option<String>) -> Addresses { | |
| 51 | + | let site = setting(site, HOSTED_SITE); | |
| 52 | + | let usercontent = if site == HOSTED_SITE { HOSTED_USERCONTENT.to_owned() } else { format!("{site}/-/usercontent") }; | |
| 45 | 53 | Addresses { | |
| 46 | − | site: setting(site, HOSTED_SITE), | |
| 47 | 54 | api: setting(api, HOSTED_API), | |
| 48 | 55 | mcp: setting(mcp, HOSTED_MCP), | |
| 56 | + | usercontent, | |
| 57 | + | site, | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// With USERCONTENT_URL, when it is set: the site's own origin is | |
| 62 | + | /// never the usercontent origin, a path on it is. | |
| 63 | + | pub fn with_usercontent(mut self, usercontent: Option<String>) -> Addresses { | |
| 64 | + | let given = setting(usercontent, ""); | |
| 65 | + | if !given.is_empty() { | |
| 66 | + | self.usercontent = if given == self.site { format!("{}/-/usercontent", self.site) } else { given }; | |
| 49 | 67 | } | |
| 68 | + | self | |
| 50 | 69 | } | |
| 51 | 70 | ||
| 52 | 71 | pub fn from_env(env: &Env) -> Addresses { | |
| 53 | 72 | let var = |name: &str| env.var(name).ok().map(|value| value.to_string()); | |
| 54 | − | Addresses::from_settings(var("SITE_URL"), var("API_URL"), var("MCP_URL")) | |
| 73 | + | Addresses::from_settings(var("SITE_URL"), var("API_URL"), var("MCP_URL")).with_usercontent(var("USERCONTENT_URL")) | |
| 55 | 74 | } | |
| 56 | 75 | ||
| 57 | 76 | /// What a client is told when it must sign in first (RFC 9728). | |
| ⋯ | |||
| 107 | 126 | "Bearer resource_metadata=\"https://mcp.g1t.sh/.well-known/oauth-protected-resource\"" | |
| 108 | 127 | ); | |
| 109 | 128 | assert_eq!(hosted.git_remote("acme", "rocket"), "https://g1t.sh/acme/rocket.git"); | |
| 129 | + | assert_eq!(hosted.usercontent, "https://g1tusercontent.com"); | |
| 110 | 130 | let on_mcp = Url::parse("https://mcp.g1t.sh/").unwrap(); | |
| 111 | 131 | assert_eq!(hosted.mcp_path(&on_mcp).as_deref(), Some("/")); | |
| 112 | 132 | let on_api = Url::parse("https://api.g1t.sh/user").unwrap(); | |
| ⋯ | |||
| 132 | 152 | assert_eq!(own.mcp_path(&url("http://localhost:8789/user")), None); | |
| 133 | 153 | assert_eq!(own.mcp_path(&url("http://127.0.0.1:8789/mcp")).as_deref(), Some("/"), "by path, whatever the host"); | |
| 134 | 154 | assert_eq!(own.git_remote("acme", "rocket"), "http://localhost:8787/acme/rocket.git"); | |
| 155 | + | assert_eq!(own.usercontent, "http://localhost:8787/-/usercontent"); | |
| 156 | + | let files = own.clone().with_usercontent(Some("https://files.example.net/".into())); | |
| 157 | + | assert_eq!(files.usercontent, "https://files.example.net"); | |
| 158 | + | let same = own.clone().with_usercontent(Some("http://localhost:8787".into())); | |
| 159 | + | assert_eq!(same.usercontent, "http://localhost:8787/-/usercontent"); | |
| 160 | + | assert_eq!(own.clone().with_usercontent(Some(" ".into())), own); | |
| 135 | 161 | } | |
| 136 | 162 | } | |
| 360 | 360 | Op::EditComment, | |
| 361 | 361 | Op::DeleteComment, | |
| 362 | 362 | Op::React, | |
| 363 | + | Op::UploadAttachment, | |
| 363 | 364 | Op::ListIssueLabels, | |
| 364 | 365 | Op::AddIssueLabels, | |
| 365 | 366 | Op::SetIssueLabels, | |
| ⋯ | |||
| 738 | 739 | Op::EditComment => "Edit a comment", | |
| 739 | 740 | Op::DeleteComment => "Delete a comment", | |
| 740 | 741 | Op::React => "React with an emoji", | |
| 742 | + | Op::UploadAttachment => "Upload an attachment", | |
| 741 | 743 | Op::ReviewPullRequest => "Review a pull request", | |
| 742 | 744 | Op::ListPullRequests => "List pull requests", | |
| 743 | 745 | Op::GetPullRequest => "Get a pull request", | |
| ⋯ | |||
| 1107 | 1109 | if op != Op::Whoami { | |
| 1108 | 1110 | responses.insert("422".into(), error_response("The input is not valid.")); | |
| 1109 | 1111 | } | |
| 1112 | + | if op == Op::UploadAttachment { | |
| 1113 | + | responses.insert("413".into(), error_response("The file is over 10 MB (`too_large`).")); | |
| 1114 | + | responses.insert("415".into(), error_response("The file is not a PNG, JPEG, GIF or WebP image by its bytes, or not the content_type given (`unsupported_type`).")); | |
| 1115 | + | } | |
| 1110 | 1116 | // Public data can be read without a token; everything else needs one. | |
| 1111 | 1117 | let scope: Vec<&str> = scope_for(op.name()).map(|scope| scope.as_str()).into_iter().collect(); | |
| 1112 | 1118 | let security = if op.needs_user() { | |
| ⋯ | |||
| 1282 | 1288 | }) | |
| 1283 | 1289 | }) | |
| 1284 | 1290 | .collect(); | |
| 1285 | − | let codes = ["unauthenticated", "payment_required", "forbidden", "not_found", "conflict", "invalid"]; | |
| 1291 | + | let codes = ["unauthenticated", "payment_required", "forbidden", "not_found", "conflict", "invalid", "too_large", "unsupported_type"]; | |
| 1286 | 1292 | json!({ | |
| 1287 | 1293 | "openapi": "3.1.0", | |
| 1288 | 1294 | "info": { | |
| 191 | 191 | EditComment, | |
| 192 | 192 | DeleteComment, | |
| 193 | 193 | React, | |
| 194 | + | UploadAttachment, | |
| 194 | 195 | ReviewPullRequest, | |
| 195 | 196 | ListPullRequests, | |
| 196 | 197 | GetPullRequest, | |
| ⋯ | |||
| 387 | 388 | call(service, method, args).await | |
| 388 | 389 | } | |
| 389 | 390 | ||
| 391 | + | /// What `upload_attachment` answers: the attachment as the work service | |
| 392 | + | /// kept it, with its address on the usercontent origin and the Markdown | |
| 393 | + | /// that shows it. | |
| 394 | + | pub(crate) fn attachment_answer(usercontent: &str, attachment: &g1t_contracts::attachments::Attachment) -> Value { | |
| 395 | + | let url = format!("{usercontent}{}", attachment.path); | |
| 396 | + | json!({ | |
| 397 | + | "id": attachment.id, | |
| 398 | + | "name": attachment.name, | |
| 399 | + | "url": url, | |
| 400 | + | "markdown": g1t_contracts::attachments::attachment_markdown(&attachment.name, &url), | |
| 401 | + | "content_type": attachment.content_type, | |
| 402 | + | "size": attachment.size, | |
| 403 | + | }) | |
| 404 | + | } | |
| 405 | + | ||
| 390 | 406 | /// Commands given the deprecated way, as `checks` or `acceptance_checks`. | |
| 391 | 407 | fn deprecated_checks(input: &Value) -> Vec<String> { | |
| 392 | 408 | let mut checks = strings(input, "checks").unwrap_or_default(); | |
| ⋯ | |||
| 812 | 828 | } | |
| 813 | 829 | ||
| 814 | 830 | impl Op { | |
| 815 | − | pub const ALL: [Op; 408] = [ | |
| 831 | + | pub const ALL: [Op; 409] = [ | |
| 816 | 832 | Op::Whoami, | |
| 817 | 833 | Op::GetWorkspace, | |
| 818 | 834 | Op::CreateWorkspace, | |
| ⋯ | |||
| 887 | 903 | Op::EditComment, | |
| 888 | 904 | Op::DeleteComment, | |
| 889 | 905 | Op::React, | |
| 906 | + | Op::UploadAttachment, | |
| 890 | 907 | Op::ReviewPullRequest, | |
| 891 | 908 | Op::ListPullRequests, | |
| 892 | 909 | Op::GetPullRequest, | |
| ⋯ | |||
| 1304 | 1321 | Op::EditComment => "edit_comment", | |
| 1305 | 1322 | Op::DeleteComment => "delete_comment", | |
| 1306 | 1323 | Op::React => "react", | |
| 1324 | + | Op::UploadAttachment => "upload_attachment", | |
| 1307 | 1325 | Op::ReviewPullRequest => "review_pull_request", | |
| 1308 | 1326 | Op::ListPullRequests => "list_pull_requests", | |
| 1309 | 1327 | Op::GetPullRequest => "get_pull_request", | |
| ⋯ | |||
| 1671 | 1689 | Op::React => { | |
| 1672 | 1690 | "React to an issue or a pull request with an emoji: to its description, or with comment_id to one of its comments. content is one of +1, -1, laugh, hooray, confused, heart, rocket and eyes; remove true takes your reaction back. Anyone who can read the repository may. Answers with the reactions it has now, each with its count and the usernames of who reacted (`g1t` for g1t, which reacts eyes to what it was asked to take on). get_issue and get_pull_request give each comment's reactions, and the description's as reactions." | |
| 1673 | 1691 | } | |
| 1692 | + | Op::UploadAttachment => { | |
| 1693 | + | "Upload an image to show in an issue's or a pull request's description or in a comment: a screenshot of a preview, say, instead of committing it to the repository. data is the file base64-encoded, name its file name (the image's alt text), content_type optional (checked against the bytes). PNG, JPEG, GIF and WebP of at most 10 MB; a larger file is refused with 413 too_large, another type (SVG, PDF, anything its bytes do not show to be one of those) with 415 unsupported_type. Anyone who may comment on the repository may. Returns its id, url (on the usercontent origin, g1tusercontent.com on g1t.sh), name, size, content_type, and markdown (``) to paste into the body or summary of create_issue, update_issue, add_comment, create_pull_request or mark_pull_request_ready. Its address is its permission: anyone given it can see the image, even one from a private repository. Over MCP the file travels in the tool call, so keep it small (a few hundred KB); `g1t attach FILE` uploads one from disk and prints the markdown." | |
| 1694 | + | } | |
| 1674 | 1695 | Op::ReviewPullRequest => { | |
| 1675 | 1696 | "Give a verdict on a pull request: approve it, or request changes and say what. Read get_pull_request_changes first. You cannot review a pull request you opened, or one g1t made for you (you are its requested_by)." | |
| 1676 | 1697 | } | |
| ⋯ | |||
| 2744 | 2765 | })), | |
| 2745 | 2766 | &["repo", "number", "content"], | |
| 2746 | 2767 | ), | |
| 2768 | + | Op::UploadAttachment => object( | |
| 2769 | + | json!({ | |
| 2770 | + | "repo": repo_schema(), | |
| 2771 | + | "name": { | |
| 2772 | + | "type": "string", | |
| 2773 | + | "description": "The file's name, such as preview.png: the image's alt text in the markdown.", | |
| 2774 | + | }, | |
| 2775 | + | "content_type": { | |
| 2776 | + | "type": "string", | |
| 2777 | + | "enum": g1t_contracts::attachments::ATTACHMENT_TYPES, | |
| 2778 | + | "description": "What the file is. Optional: it is read from the bytes, and refused when it says otherwise.", | |
| 2779 | + | }, | |
| 2780 | + | "data": { | |
| 2781 | + | "type": "string", | |
| 2782 | + | "contentEncoding": "base64", | |
| 2783 | + | "description": "The file, base64-encoded: PNG, JPEG, GIF or WebP of at most 10 MB.", | |
| 2784 | + | }, | |
| 2785 | + | }), | |
| 2786 | + | &["repo", "name", "data"], | |
| 2787 | + | ), | |
| 2747 | 2788 | Op::ReviewPullRequest => object( | |
| 2748 | 2789 | numbered(json!({ | |
| 2749 | 2790 | "verdict": { "type": "string", "enum": ["approve", "request_changes"] }, | |
| ⋯ | |||
| 5330 | 5371 | }; | |
| 5331 | 5372 | pass(work, "react", &asked).await | |
| 5332 | 5373 | } | |
| 5374 | + | Op::UploadAttachment => { | |
| 5375 | + | let asked = g1t_contracts::attachments::UploadAttachmentArgs { | |
| 5376 | + | actor: actor(), | |
| 5377 | + | repo, | |
| 5378 | + | name: text(input, "name"), | |
| 5379 | + | content_type: optional_text(input, "content_type"), | |
| 5380 | + | data: text(input, "data"), | |
| 5381 | + | }; | |
| 5382 | + | if asked.data.trim().is_empty() { | |
| 5383 | + | return failed(FailureCode::Invalid, "Give data: the file, base64-encoded."); | |
| 5384 | + | } | |
| 5385 | + | let kept: Outcome<g1t_contracts::attachments::Attachment> = call(work, "upload_attachment", &asked).await?; | |
| 5386 | + | match kept { | |
| 5387 | + | Outcome::Ok(attachment) => ok(&attachment_answer(&services.addresses.usercontent, &attachment)), | |
| 5388 | + | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 5389 | + | } | |
| 5390 | + | } | |
| 5333 | 5391 | Op::EditComment | Op::DeleteComment => { | |
| 5334 | 5392 | let asked = CommentActionArgs { | |
| 5335 | 5393 | actor: actor(), | |
| 1590 | 1590 | }, | |
| 1591 | 1591 | "notes": "Answers `204` with no body. Its author, or someone with the Maintain role or higher. A review that approved or requested changes answers `409`: edit it instead." | |
| 1592 | 1592 | }, | |
| 1593 | + | "upload_attachment": { | |
| 1594 | + | "params": { | |
| 1595 | + | "owner": "flagon-io", | |
| 1596 | + | "name": "session-lab" | |
| 1597 | + | }, | |
| 1598 | + | "request": { | |
| 1599 | + | "name": "preview.png", | |
| 1600 | + | "content_type": "image/png", | |
| 1601 | + | "data": "iVBORw0KGgoAAAANSUhEUgAA…" | |
| 1602 | + | }, | |
| 1603 | + | "response": { | |
| 1604 | + | "id": "3f9c2a7e5b1d48c6a0e7f4b2d9c81a6e5f3b7d2c9a4e8f1b6c0d5a7e2f9b3c41", | |
| 1605 | + | "name": "preview.png", | |
| 1606 | + | "url": "https://g1tusercontent.com/attachments/3f9c2a7e5b1d48c6a0e7f4b2d9c81a6e5f3b7d2c9a4e8f1b6c0d5a7e2f9b3c41", | |
| 1607 | + | "markdown": "", | |
| 1608 | + | "content_type": "image/png", | |
| 1609 | + | "size": 184213 | |
| 1610 | + | }, | |
| 1611 | + | "notes": "`data` is the file base64-encoded: `base64 -w0 preview.png`. Paste `markdown` into the `body` of an issue, pull request or comment, or a pull request's `summary`. The type is read from the bytes; a file over 10 MB is refused with `413` (`too_large`), one that is not a PNG, JPEG, GIF or WebP with `415` (`unsupported_type`). From a shell, `g1t attach preview.png` does all of it and prints the markdown." | |
| 1612 | + | }, | |
| 1593 | 1613 | "react": { | |
| 1594 | 1614 | "params": { | |
| 1595 | 1615 | "number": 12, |
| 1060 | 1060 | route("PATCH", "/repos/:owner/:name/issues/comments/:comment_id", Op::EditComment, &[]), | |
| 1061 | 1061 | route("DELETE", "/repos/:owner/:name/issues/comments/:comment_id", Op::DeleteComment, &[]), | |
| 1062 | 1062 | route("POST", "/repos/:owner/:name/issues/:number/reactions", Op::React, &[]), | |
| 1063 | + | // An image to show in an issue's, a pull request's or a comment's Markdown. | |
| 1064 | + | route("POST", "/repos/:owner/:name/attachments", Op::UploadAttachment, &[]), | |
| 1063 | 1065 | route( | |
| 1064 | 1066 | "GET", | |
| 1065 | 1067 | "/repos/:owner/:name/pulls", | |
| ⋯ | |||
| 1594 | 1596 | // Still an issue's comments, labels and subscription. | |
| 1595 | 1597 | assert_eq!(op("POST", "/repos/acme/web/issues/7/comments"), Op::AddComment); | |
| 1596 | 1598 | assert_eq!(op("POST", "/repos/acme/web/issues/7/reactions"), Op::React); | |
| 1599 | + | assert_eq!(op("POST", "/repos/acme/web/attachments"), Op::UploadAttachment); | |
| 1597 | 1600 | assert_eq!(op("DELETE", "/repos/acme/web/issues/7/labels"), Op::RemoveIssueLabels); | |
| 1598 | 1601 | assert_eq!(op("DELETE", "/repos/acme/web/issues/7/subscription"), Op::DeleteThreadSubscription); | |
| 1599 | 1602 | } | |
| 149 | 149 | Tool { | |
| 150 | 150 | name: "issue", | |
| 151 | 151 | title: "Issues", | |
| 152 | − | description: "Issues: what should change. Before working on one, `work` gives everything about it (definition of done, who is on it, its pull requests, what it waits on); `claim` says you are on it (advisory and expiring, never a lock), `release` or `handoff` when you stop. `update` with blocked_by sets the issues that must land first. Issues and pull requests share numbers; `comment` works on either.", | |
| 152 | + | description: "Issues: what should change. Before working on one, `work` gives everything about it (definition of done, who is on it, its pull requests, what it waits on); `claim` says you are on it (advisory and expiring, never a lock), `release` or `handoff` when you stop. `update` with blocked_by sets the issues that must land first. Issues and pull requests share numbers; `comment` works on either, and `attach` uploads an image (a screenshot) whose markdown goes in either's body or a comment.", | |
| 153 | 153 | default_action: None, | |
| 154 | 154 | actions: &[ | |
| 155 | 155 | a("list", Op::ListIssues, "Issues on a repository, newest first"), | |
| ⋯ | |||
| 166 | 166 | a("edit_comment", Op::EditComment, "Change a comment's text: your own, or any with the Maintain role"), | |
| 167 | 167 | a("delete_comment", Op::DeleteComment, "Delete a comment: your own, or any with the Maintain role"), | |
| 168 | 168 | a("react", Op::React, "React with an emoji to it, or with comment_id to one of its comments; remove takes it back"), | |
| 169 | + | a("attach", Op::UploadAttachment, "Upload an image (base64, PNG/JPEG/GIF/WebP, at most 10 MB; keep it small over MCP) for its markdown, to paste into a body, summary or comment"), | |
| 169 | 170 | a("import", Op::ImportIssue, "Open an issue from a Jira, Linear or Sentry item"), | |
| 170 | 171 | a("work", Op::Coordination(CoordinationOp::GetWork), "Everything to pick it up: done means, claims, sessions, pull requests, blockers, decisions, recent events"), | |
| 171 | 172 | a("claim", Op::Coordination(CoordinationOp::ClaimIssue), "Say you are working on it; lists who else is, and what it is blocked by"), | |
| 234 | 234 | `@me` is you. At a terminal, `g1t issue create` without `--title` asks for | |
| 235 | 235 | one and opens your editor for the body. | |
| 236 | 236 | ||
| 237 | + | ### Images in descriptions and comments | |
| 238 | + | ||
| 239 | + | `g1t attach` uploads images, such as a screenshot of a preview, and prints the | |
| 240 | + | Markdown that shows each, to paste into a description or a comment: | |
| 241 | + | ||
| 242 | + | ```sh | |
| 243 | + | g1t attach preview.png # prints  | |
| 244 | + | g1t attach before.png after.png --json # id, name, url, markdown, content_type and size of each | |
| 245 | + | g1t pr comment 14 -b "The preview, after the fix:" --attach preview.png | |
| 246 | + | g1t pr ready --summary "Names the caller." --attach preview.png | |
| 247 | + | ``` | |
| 248 | + | ||
| 249 | + | `--attach FILE` (repeat it for more) on `g1t issue create`, `g1t issue comment`, | |
| 250 | + | `g1t pr create`, `g1t pr comment` and `g1t pr ready` uploads each file and adds | |
| 251 | + | its Markdown after the text. An attachment is a PNG, JPEG, GIF or WebP image of | |
| 252 | + | at most 10 MB, judged by its bytes. It is served from g1t's usercontent address, | |
| 253 | + | never from g1t.sh, and its address is its permission: anyone given it can see | |
| 254 | + | the image, even from a private repository. Never commit an image to the | |
| 255 | + | repository just to link it. Through the API it is | |
| 256 | + | [`upload_attachment`](/reference/api/issues/upload-attachment/). | |
| 257 | + | ||
| 237 | 258 | ### Pull requests | |
| 238 | 259 | ||
| 239 | 260 | From a branch with your commits on it: |
| 340 | 340 | `GET /repos/{owner}/{name}/pulls/{number}`: each with its `content`, | |
| 341 | 341 | `count` and the `users` who reacted. | |
| 342 | 342 | ||
| 343 | + | ## Images | |
| 344 | + | ||
| 345 | + | To show an image in a pull request's or an issue's description, or in a | |
| 346 | + | comment (a screenshot of a preview, say), upload it and paste the Markdown | |
| 347 | + | you get back. From a shell, `g1t attach preview.png` prints | |
| 348 | + | ``, and `--attach | |
| 349 | + | preview.png` on `g1t pr create`, `g1t pr ready`, `g1t pr comment`, | |
| 350 | + | `g1t issue create` and `g1t issue comment` adds it for you (see the | |
| 351 | + | [CLI guide](/guides/cli/#images-in-descriptions-and-comments)). Through the API it is | |
| 352 | + | `POST /repos/{owner}/{name}/attachments` with `name`, the file | |
| 353 | + | base64-encoded in `data`, and optionally `content_type`; it answers with | |
| 354 | + | `id`, `url`, `content_type`, `size` and `markdown`. On the MCP server it is | |
| 355 | + | the `issue` tool's `attach` action. | |
| 356 | + | ||
| 357 | + | An attachment is a PNG, JPEG, GIF or WebP image of at most 10 MB, judged by | |
| 358 | + | its bytes: a larger file is refused with `413` (`too_large`), anything else | |
| 359 | + | with `415` (`unsupported_type`). Anyone who may comment on the repository | |
| 360 | + | may upload one, with `issues:write`. It is served from g1t's usercontent | |
| 361 | + | address, never g1t.sh, and its address is its permission: anyone given the | |
| 362 | + | link can see the image, even one from a private repository. A purged | |
| 363 | + | repository's attachments are deleted with it. | |
| 364 | + | ||
| 343 | 365 | ## Commit comments | |
| 344 | 366 | ||
| 345 | 367 | You can comment on any commit: on the whole commit, under its changes on |
| 156 | 156 | | `API_PORT` | `8789` | The port the API and the MCP server are published on | | |
| 157 | 157 | | `API_URL` | `PUBLIC_URL`'s host on `API_PORT` | The address of the API, as people and applications reach it. It is also the OAuth issuer. Set it when the API is behind a proxy, for example `https://api.git.example.com`. | | |
| 158 | 158 | | `MCP_URL` | `API_URL/mcp` | The address of the MCP server. | | |
| 159 | − | | `USERCONTENT_URL` | `PUBLIC_URL/-/usercontent` | Where [raw files](/guides/git/#raw-files) and avatars are served. Set it to a host of its own (another domain, not a subdomain of `PUBLIC_URL`'s, for example `https://files.example.net`) that your proxy sends to the same port as `PUBLIC_URL`, keeping the `Host` header, so a file in a repository can never reach the site's session cookie. Unset, they are served under `PUBLIC_URL`, still as plain text or images that cannot run script. | | |
| 159 | + | | `USERCONTENT_URL` | `PUBLIC_URL/-/usercontent` | Where [raw files](/guides/git/#raw-files), avatars and images attached to issues, pull requests and comments are served (the API names attachments by it too). Set it to a host of its own (another domain, not a subdomain of `PUBLIC_URL`'s, for example `https://files.example.net`) that your proxy sends to the same port as `PUBLIC_URL`, keeping the `Host` header, so a file in a repository can never reach the site's session cookie. Unset, they are served under `PUBLIC_URL`, still as plain text or images that cannot run script. | | |
| 160 | 160 | | `MAILPIT_PORT` | `8025` | The port of the Mailpit inbox | | |
| 161 | 161 | | `MAIL_FROM` | `g1t <noreply@localhost>` | The sender of g1t's email | | |
| 162 | 162 | | `MAIL_URL` | `http://mailpit:8025` | The Mailpit server g1t sends mail through | |
| 295 | 295 | context`, `g1t work claim`, `g1t pr create`, `g1t pr checks --watch`. | |
| 296 | 296 | It works on a branch and a pull request, except in a repository with no | |
| 297 | 297 | commits yet, where there is nothing to open one against: it pushes the | |
| 298 | − | first version straight to the default branch. | |
| 298 | + | first version straight to the default branch. To show a screenshot of its | |
| 299 | + | preview in the pull request or a comment, it uploads the image with | |
| 300 | + | [`g1t attach`](/guides/cli/#images-in-descriptions-and-comments) (or | |
| 301 | + | `--attach` on `g1t pr create`, `g1t pr ready` and `g1t pr comment`) and | |
| 302 | + | pastes the Markdown it prints, rather than committing the image. | |
| 299 | 303 | g1t's MCP server is attached too, for anything the CLI has no command | |
| 300 | 304 | for. Its sandbox reaches the internet as a CI runner does, and it can | |
| 301 | 305 | search and read the web (web search and web fetch), for a fact the work |
| 295 | 295 | | `-t, --title <TITLE>` | | | |
| 296 | 296 | | `-b, --body <BODY>` | The text. Markdown. | | |
| 297 | 297 | | `-F, --body-file <FILE>` | Read the text from a file, or - for stdin. | | |
| 298 | + | | `--attach <FILE>` | An image to show after the text (PNG, JPEG, GIF or WebP, at most 10 MB): uploaded, and its Markdown added. Repeat for more. | | |
| 298 | 299 | | `-l, --label <LABEL>` | A label; repeat for more. | | |
| 299 | 300 | | `-a, --assignee <ASSIGNEE>` | Someone to assign; repeat for more (@me for you). | | |
| 300 | 301 | | `-m, --milestone <MILESTONE>` | A milestone's number. | | |
| 301 | 302 | | `-w, --web` | Open the new-issue page in the browser instead. | | |
| 302 | 303 | ||
| 303 | − | Calls [`create_issue`](/reference/api/issues/create-issue/), [`update_issue`](/reference/api/issues/update-issue/). | |
| 304 | + | Calls [`create_issue`](/reference/api/issues/create-issue/), [`update_issue`](/reference/api/issues/update-issue/), [`upload_attachment`](/reference/api/issues/upload-attachment/). | |
| 304 | 305 | ||
| 305 | 306 | ### g1t issue comment | |
| 306 | 307 | ||
| ⋯ | |||
| 315 | 316 | | `[NUMBER]` | The number; for a pull request, the current branch's when left out. | | |
| 316 | 317 | | `-b, --body <BODY>` | The text. Markdown. | | |
| 317 | 318 | | `-F, --body-file <FILE>` | Read the text from a file, or - for stdin. | | |
| 319 | + | | `--attach <FILE>` | An image to show after the text (PNG, JPEG, GIF or WebP, at most 10 MB): uploaded, and its Markdown added. Repeat for more. | | |
| 318 | 320 | ||
| 319 | − | Calls [`add_comment`](/reference/api/issues/add-comment/). | |
| 321 | + | Calls [`add_comment`](/reference/api/issues/add-comment/), [`upload_attachment`](/reference/api/issues/upload-attachment/). | |
| 320 | 322 | ||
| 321 | 323 | ### g1t issue close | |
| 322 | 324 | ||
| ⋯ | |||
| 425 | 427 | | `-t, --title <TITLE>` | | | |
| 426 | 428 | | `-b, --body <BODY>` | The text. Markdown. | | |
| 427 | 429 | | `-F, --body-file <FILE>` | Read the text from a file, or - for stdin. | | |
| 430 | + | | `--attach <FILE>` | An image to show after the text (PNG, JPEG, GIF or WebP, at most 10 MB): uploaded, and its Markdown added. Repeat for more. | | |
| 428 | 431 | | `-B, --base <BASE>` | The branch to merge into; the default branch when left out. | | |
| 429 | 432 | | `-H, --head <HEAD>` | The branch with the changes; the current one when left out. | | |
| 430 | 433 | | `-i, --issue <ISSUE>` | The issue it is for. Without a branch, starts a draft in a working copy of its own. | | |
| ⋯ | |||
| 433 | 436 | | `--no-push` | Do not push the branch first. | | |
| 434 | 437 | | `--head-repo <OWNER/NAME>` | The fork, OWNER/NAME, that holds the branch. In a clone of your fork (origin) whose upstream is the repository, origin when left out. | | |
| 435 | 438 | ||
| 436 | − | Calls [`create_pull_request`](/reference/api/pull-requests/create-pull-request/), [`get_repo`](/reference/api/repositories/get-repo/), [`convert_pull_request_to_draft`](/reference/api/pull-requests/convert-pull-request-to-draft/). | |
| 439 | + | Calls [`create_pull_request`](/reference/api/pull-requests/create-pull-request/), [`get_repo`](/reference/api/repositories/get-repo/), [`convert_pull_request_to_draft`](/reference/api/pull-requests/convert-pull-request-to-draft/), [`upload_attachment`](/reference/api/issues/upload-attachment/). | |
| 437 | 440 | ||
| 438 | 441 | ### g1t pr checkout | |
| 439 | 442 | ||
| ⋯ | |||
| 558 | 561 | | --- | --- | | |
| 559 | 562 | | `[NUMBER]` | | | |
| 560 | 563 | | `-s, --summary <SUMMARY>` | What changed and why; it becomes the description. The current one when left out. | | |
| 564 | + | | `--attach <FILE>` | An image to show after the summary (PNG, JPEG, GIF or WebP, at most 10 MB): uploaded, and its Markdown added. Repeat for more. | | |
| 561 | 565 | | `--undo` | Turn it back into a draft. | | |
| 562 | 566 | ||
| 563 | − | Calls [`mark_pull_request_ready`](/reference/api/pull-requests/mark-pull-request-ready/), [`convert_pull_request_to_draft`](/reference/api/pull-requests/convert-pull-request-to-draft/). | |
| 567 | + | Calls [`mark_pull_request_ready`](/reference/api/pull-requests/mark-pull-request-ready/), [`convert_pull_request_to_draft`](/reference/api/pull-requests/convert-pull-request-to-draft/), [`upload_attachment`](/reference/api/issues/upload-attachment/). | |
| 564 | 568 | ||
| 565 | 569 | ### g1t pr comment | |
| 566 | 570 | ||
| ⋯ | |||
| 575 | 579 | | `[NUMBER]` | The number; for a pull request, the current branch's when left out. | | |
| 576 | 580 | | `-b, --body <BODY>` | The text. Markdown. | | |
| 577 | 581 | | `-F, --body-file <FILE>` | Read the text from a file, or - for stdin. | | |
| 582 | + | | `--attach <FILE>` | An image to show after the text (PNG, JPEG, GIF or WebP, at most 10 MB): uploaded, and its Markdown added. Repeat for more. | | |
| 578 | 583 | ||
| 579 | − | Calls [`add_comment`](/reference/api/issues/add-comment/). | |
| 584 | + | Calls [`add_comment`](/reference/api/issues/add-comment/), [`upload_attachment`](/reference/api/issues/upload-attachment/). | |
| 580 | 585 | ||
| 581 | 586 | ### g1t pr close | |
| 582 | 587 | ||
| ⋯ | |||
| 1396 | 1401 | ||
| 1397 | 1402 | Calls [`why`](/reference/api/decisions-and-why/why/). | |
| 1398 | 1403 | ||
| 1404 | + | ## g1t attach | |
| 1405 | + | ||
| 1406 | + | Upload images, such as a screenshot, to show in an issue, a pull request or a comment; prints their Markdown. | |
| 1407 | + | ||
| 1408 | + | ```text | |
| 1409 | + | g1t attach [OPTIONS] <FILE>... | |
| 1410 | + | ``` | |
| 1411 | + | ||
| 1412 | + | | Argument or flag | | | |
| 1413 | + | | --- | --- | | |
| 1414 | + | | `<FILE>` | The files: PNG, JPEG, GIF or WebP, at most 10 MB each. | | |
| 1415 | + | ||
| 1416 | + | ```text | |
| 1417 | + | Prints each image's Markdown, , to paste into a description or a comment; --json prints | |
| 1418 | + | { id, name, url, markdown, content_type, size } (a list for several files). The image is served from | |
| 1419 | + | g1t's usercontent address, and anyone given that address can see it, even from a private repository. | |
| 1420 | + | --attach FILE on g1t issue create, g1t issue comment, g1t pr create, g1t pr comment and g1t pr ready | |
| 1421 | + | uploads and adds the Markdown for you: | |
| 1422 | + | g1t attach preview.png | |
| 1423 | + | g1t pr comment --body 'The preview, after the fix:' --attach preview.png | |
| 1424 | + | ``` | |
| 1425 | + | ||
| 1426 | + | Calls [`upload_attachment`](/reference/api/issues/upload-attachment/). | |
| 1427 | + | ||
| 1399 | 1428 | ## g1t api | |
| 1400 | 1429 | ||
| 1401 | 1430 | Call any API operation or path, with your token. | |
| 344 | 344 | Issues: what should change. Before working on one, `work` gives everything | |
| 345 | 345 | about it, and `claim` says you are on it; see | |
| 346 | 346 | [sessions, claims and dependencies](/guides/sessions/). Issues and pull | |
| 347 | − | requests share numbers, so `comment` works on either. | |
| 347 | + | requests share numbers, so `comment` works on either. `attach` uploads an | |
| 348 | + | image, such as a screenshot, and gives the Markdown that shows it in either's | |
| 349 | + | description or a comment. | |
| 348 | 350 | ||
| 349 | 351 | | Action | What it does | Required | Scope | | |
| 350 | 352 | | --- | --- | --- | --- | | |
| ⋯ | |||
| 361 | 363 | | [`comment`](/reference/api/issues/add-comment/) | Comment on an issue or a pull request; with `path` and `line`, on one line of a pull request's change. | `repo`, `number`, `body` | `issues:write` | | |
| 362 | 364 | | [`edit_comment`](/reference/api/issues/edit-comment/) | Change a comment's text, by its id from `get`. Your own, or anyone's with the Maintain role or higher. Notes of what happened cannot be edited. | `repo`, `comment_id`, `body` | `issues:write` | | |
| 363 | 365 | | [`delete_comment`](/reference/api/issues/delete-comment/) | Delete a comment, by its id. Your own, or anyone's with the Maintain role or higher. A review that approved or requested changes cannot be deleted. | `repo`, `comment_id` | `issues:write` | | |
| 366 | + | | [`attach`](/reference/api/issues/upload-attachment/) | Upload an image to show in a description or a comment: `data` base64-encoded, `name` its file name. PNG, JPEG, GIF or WebP of at most 10 MB, by its bytes (`413` `too_large`, `415` `unsupported_type` otherwise). Returns `id`, `url`, `content_type`, `size` and `markdown` (``) to paste into a `body` or `summary`. The file travels in the tool call, so keep it small (a few hundred KB); `g1t attach FILE` sends one from disk. Anyone who may comment on the repository may. | `repo`, `name`, `data` | `issues:write` | | |
| 364 | 367 | | [`import`](/reference/api/integrations/import-issue/) | Open an issue from a Jira, Linear or Sentry item, linked to it. | `repo`, `reference` | `issues:write` | | |
| 365 | 368 | | [`work`](/reference/api/claims-and-work/get-work/) | Everything to pick an issue up: its definition of done and required checks, active claims, sessions, pull requests (checks, mergeability, overlaps, queue place), what it is blocked by and blocks, decisions, and its recent coordination events. | `repo`, `number` | `issues:read` | | |
| 366 | 369 | | [`claim`](/reference/api/claims-and-work/claim-issue/) | Say you are working on an open issue: advisory and expiring (`ttl_minutes`, 30 unless you say, at most 240), never a lock. Returns `others` already on it and what it is `blocked_by`. Claiming again, or a heartbeat of its `session_id`, renews it. With `title` (and `body`) instead of `number`, it [opens the issue first](/reference/api/claims-and-work/claim-new-issue/), claims it, attaches the `session_id` session to it, and returns it as `issue`. | `repo`, and `number` or `title` | `issues:write` | | |
Binary or large file; its contents are not shown.
| 8 | 8 | * <usercontent>/avatars/<sha256> an uploaded avatar | |
| 9 | 9 | * <usercontent>/emoji/<sha256> a workspace's custom emoji | |
| 10 | 10 | * <usercontent>/production-images/<sha256> a project's uploaded production image | |
| 11 | + | * <usercontent>/attachments/<id> an image in an issue, pull request or comment | |
| 11 | 12 | * | |
| 12 | 13 | * A public repository's files are there for anyone. A private one's carry | |
| 13 | 14 | * `?token=`, a signature the site makes for someone who can read the |
| 14 | 14 | export const EMOJI_PATH = /^\/emoji\/([0-9a-f]{64})$/; | |
| 15 | 15 | /** A project's uploaded production image, by the SHA-256 of its bytes: kept by projects under `production/<hash>` in the same namespace. */ | |
| 16 | 16 | export const PRODUCTION_IMAGE_PATH = /^\/production-images\/([0-9a-f]{64})$/; | |
| 17 | + | /** | |
| 18 | + | * An image put in an issue's, a pull request's or a comment's Markdown, by its | |
| 19 | + | * random id (256 bits, so its address is its permission): kept by work under | |
| 20 | + | * `attachments/<id>` in the same namespace (services/work/src/attachments.rs). | |
| 21 | + | */ | |
| 22 | + | export const ATTACHMENT_PATH = /^\/attachments\/([0-9a-f]{64})$/; | |
| 17 | 23 | /** A file put in a Docs page, by its random key: kept by the artifacts service (services/artifacts). */ | |
| 18 | 24 | export const DOCS_FILE_PATH = /^\/docs-files\/([0-9a-f]{64})$/; | |
| 19 | 25 | /** The only types identity stores, having checked each image's bytes. */ | |
| ⋯ | |||
| 46 | 52 | if (emoji) return serveAvatar(env, ctx, method, `emoji/${emoji[1]!}`, new URL(request.url).origin); | |
| 47 | 53 | const production = PRODUCTION_IMAGE_PATH.exec(path); | |
| 48 | 54 | if (production) return serveAvatar(env, ctx, method, `production/${production[1]!}`, new URL(request.url).origin); | |
| 55 | + | const attachment = ATTACHMENT_PATH.exec(path); | |
| 56 | + | if (attachment) return serveAvatar(env, ctx, method, `attachments/${attachment[1]!}`, new URL(request.url).origin); | |
| 49 | 57 | const docsFile = DOCS_FILE_PATH.exec(path); | |
| 50 | 58 | if (docsFile) return serveDocsFile(env, method, docsFile[1]!); | |
| 51 | 59 | const file = parseRawPath(path); | |
| ⋯ | |||
| 88 | 96 | } | |
| 89 | 97 | ||
| 90 | 98 | /** | |
| 91 | − | * An uploaded avatar, a custom emoji (`key` is then `emoji/<hash>`) or a | |
| 92 | − | * production image (`production/<hash>`). Its | |
| 99 | + | * An uploaded avatar, a custom emoji (`key` is then `emoji/<hash>`), a | |
| 100 | + | * production image (`production/<hash>`) or an attachment | |
| 101 | + | * (`attachments/<id>`, at the same path). Its | |
| 93 | 102 | * address is its hash, so it never changes and is | |
| 94 | 103 | * kept for good: each data centre keeps it in its cache after the first | |
| 95 | 104 | * view, and storage is read about once per place, not once per visitor. | |
| ⋯ | |||
| 99 | 108 | async function serveAvatar(env: Env, ctx: ExecutionContext, method: string, key: string, origin: string): Promise<Response> { | |
| 100 | 109 | // The Workers runtime's own cache, which the DOM types do not know. | |
| 101 | 110 | const cache = (caches as unknown as { default: Cache }).default; | |
| 102 | − | const path = key.startsWith("emoji/") ? key : key.startsWith("production/") ? `production-images/${key.slice("production/".length)}` : `avatars/${key}`; | |
| 111 | + | const path = key.startsWith("emoji/") || key.startsWith("attachments/") ? key : key.startsWith("production/") ? `production-images/${key.slice("production/".length)}` : `avatars/${key}`; | |
| 103 | 112 | const cacheKey = new Request(`${origin}/${path}`, { method: "GET" }); | |
| 104 | 113 | const cached = await cache.match(cacheKey); | |
| 105 | 114 | if (cached) { | |
| 1 | + | //! Files put in an issue's, a pull request's or a comment's Markdown: a | |
| 2 | + | //! screenshot of a preview, a recording's still. The work service keeps | |
| 3 | + | //! them (`upload_attachment`), with whoever may comment on the repository | |
| 4 | + | //! allowed to add one, and the usercontent origin serves each at | |
| 5 | + | //! `<usercontent>/attachments/<id>` (apps/web/workers/usercontent.ts), | |
| 6 | + | //! never the site. An id is 256 random bits, so its address is its | |
| 7 | + | //! permission, as a Docs file's is: a private repository's attachment is | |
| 8 | + | //! seen by whoever is given its address, as its Markdown gives it. | |
| 9 | + | ||
| 10 | + | use serde::{Deserialize, Serialize}; | |
| 11 | + | ||
| 12 | + | use crate::User; | |
| 13 | + | use crate::repos::RepoPath; | |
| 14 | + | ||
| 15 | + | /// The largest attachment, in bytes: 10 MB. | |
| 16 | + | pub const MAX_ATTACHMENT_BYTES: usize = 10 * 1024 * 1024; | |
| 17 | + | ||
| 18 | + | /// What an attachment may be, as its bytes show it: PNG, JPEG, GIF or WebP. | |
| 19 | + | pub const ATTACHMENT_TYPES: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"]; | |
| 20 | + | ||
| 21 | + | /// The longest name kept, in characters. | |
| 22 | + | pub const MAX_ATTACHMENT_NAME_CHARS: usize = 200; | |
| 23 | + | ||
| 24 | + | /// Where attachments are kept in the usercontent namespace (`AVATARS`): | |
| 25 | + | /// `attachments/<id>`, with [`AttachmentMeta`]. | |
| 26 | + | pub const ATTACHMENT_KEY_PREFIX: &str = "attachments/"; | |
| 27 | + | ||
| 28 | + | /// `upload_attachment`: keep a file to show in Markdown on `repo`, for | |
| 29 | + | /// someone who may comment there. `data` is the file, base64-encoded; its | |
| 30 | + | /// type is read from its bytes, and a `content_type` that says otherwise | |
| 31 | + | /// is refused. Returns `Outcome<Attachment>`: too large is | |
| 32 | + | /// `FailureCode::TooLarge`, another type `FailureCode::UnsupportedType`. | |
| 33 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 34 | + | pub struct UploadAttachmentArgs { | |
| 35 | + | pub actor: User, | |
| 36 | + | pub repo: RepoPath, | |
| 37 | + | /// The file's name, such as `preview.png`: the image's alt text. | |
| 38 | + | pub name: String, | |
| 39 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 40 | + | pub content_type: Option<String>, | |
| 41 | + | pub data: String, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// An attachment kept: `path` is under the usercontent origin. | |
| 45 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 46 | + | pub struct Attachment { | |
| 47 | + | pub id: String, | |
| 48 | + | pub name: String, | |
| 49 | + | pub content_type: String, | |
| 50 | + | pub size: u64, | |
| 51 | + | /// `/attachments/<id>`. | |
| 52 | + | pub path: String, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// What is kept with an attachment's bytes, as KV metadata (at most 1 KB). | |
| 56 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 57 | + | #[serde(rename_all = "camelCase")] | |
| 58 | + | pub struct AttachmentMeta { | |
| 59 | + | /// As the usercontent origin serves it. | |
| 60 | + | pub content_type: String, | |
| 61 | + | pub name: String, | |
| 62 | + | pub repo_id: String, | |
| 63 | + | pub user_id: String, | |
| 64 | + | pub size: u64, | |
| 65 | + | /// Milliseconds since the epoch. | |
| 66 | + | pub at: u64, | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// `/attachments/<id>`: where an attachment is under the usercontent origin. | |
| 70 | + | pub fn attachment_path(id: &str) -> String { | |
| 71 | + | format!("/attachments/{id}") | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// Whether `id` is an attachment's: 64 lowercase hex digits. | |
| 75 | + | pub fn is_attachment_id(id: &str) -> bool { | |
| 76 | + | id.len() == 64 && id.bytes().all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// The type a file's first bytes show it to be, if it is one an attachment may be. | |
| 80 | + | pub fn sniff_attachment(bytes: &[u8]) -> Option<&'static str> { | |
| 81 | + | if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) { | |
| 82 | + | Some("image/png") | |
| 83 | + | } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { | |
| 84 | + | Some("image/jpeg") | |
| 85 | + | } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { | |
| 86 | + | Some("image/gif") | |
| 87 | + | } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" { | |
| 88 | + | Some("image/webp") | |
| 89 | + | } else { | |
| 90 | + | None | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /// A content type as given (`image/PNG; charset=…`), as one of | |
| 95 | + | /// [`ATTACHMENT_TYPES`] compares it; `image/jpg` is `image/jpeg`. | |
| 96 | + | pub fn normalize_content_type(given: &str) -> String { | |
| 97 | + | let kind = given.split(';').next().unwrap_or_default().trim().to_ascii_lowercase(); | |
| 98 | + | if kind == "image/jpg" { "image/jpeg".to_owned() } else { kind } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// A file's name as kept: its last path segment, without control | |
| 102 | + | /// characters, at most [`MAX_ATTACHMENT_NAME_CHARS`]; `image` when nothing is left. | |
| 103 | + | pub fn attachment_name(given: &str) -> String { | |
| 104 | + | let last = given.rsplit(['/', '\\']).next().unwrap_or_default(); | |
| 105 | + | let name: String = last.chars().filter(|c| !c.is_control()).take(MAX_ATTACHMENT_NAME_CHARS).collect(); | |
| 106 | + | let name = name.trim(); | |
| 107 | + | if name.is_empty() { "image".to_owned() } else { name.to_owned() } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// The Markdown that shows the attachment at `url`: ``, with | |
| 111 | + | /// the brackets and backslashes of its name escaped. | |
| 112 | + | pub fn attachment_markdown(name: &str, url: &str) -> String { | |
| 113 | + | let mut alt = String::with_capacity(name.len()); | |
| 114 | + | for c in name.chars() { | |
| 115 | + | if matches!(c, '[' | ']' | '\\') { | |
| 116 | + | alt.push('\\'); | |
| 117 | + | } | |
| 118 | + | alt.push(c); | |
| 119 | + | } | |
| 120 | + | format!("") | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | #[cfg(test)] | |
| 124 | + | mod tests { | |
| 125 | + | use super::*; | |
| 126 | + | ||
| 127 | + | #[test] | |
| 128 | + | fn types_are_read_from_bytes() { | |
| 129 | + | assert_eq!(sniff_attachment(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0]), Some("image/png")); | |
| 130 | + | assert_eq!(sniff_attachment(&[0xff, 0xd8, 0xff, 0xe0]), Some("image/jpeg")); | |
| 131 | + | assert_eq!(sniff_attachment(b"GIF89a...."), Some("image/gif")); | |
| 132 | + | assert_eq!(sniff_attachment(b"RIFF\0\0\0\0WEBPVP8 "), Some("image/webp")); | |
| 133 | + | assert_eq!(sniff_attachment(b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>"), None); | |
| 134 | + | assert_eq!(sniff_attachment(b"%PDF-1.7"), None); | |
| 135 | + | assert_eq!(sniff_attachment(&[]), None); | |
| 136 | + | for kind in ATTACHMENT_TYPES { | |
| 137 | + | assert!(kind.starts_with("image/")); | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | #[test] | |
| 142 | + | fn names_and_markdown() { | |
| 143 | + | assert_eq!(attachment_name("shots/preview.png"), "preview.png"); | |
| 144 | + | assert_eq!(attachment_name("C:\\tmp\\a b.png"), "a b.png"); | |
| 145 | + | assert_eq!(attachment_name(" \n"), "image"); | |
| 146 | + | assert_eq!(attachment_name(&"x".repeat(500)).chars().count(), MAX_ATTACHMENT_NAME_CHARS); | |
| 147 | + | assert_eq!(attachment_markdown("preview.png", "https://g1tusercontent.com/attachments/ab"), ""); | |
| 148 | + | assert_eq!(attachment_markdown("a]b[c\\", "u"), "![a\\]b\\[c\\\\](u)"); | |
| 149 | + | assert_eq!(normalize_content_type("Image/JPG; q=1"), "image/jpeg"); | |
| 150 | + | assert_eq!(attachment_path("ab"), "/attachments/ab"); | |
| 151 | + | assert!(is_attachment_id(&"0a".repeat(32))); | |
| 152 | + | assert!(!is_attachment_id(&"0A".repeat(32))); | |
| 153 | + | assert!(!is_attachment_id("abc")); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | #[test] | |
| 157 | + | fn meta_fits_in_kv_metadata() { | |
| 158 | + | let meta = AttachmentMeta { | |
| 159 | + | content_type: "image/webp".into(), | |
| 160 | + | name: "é".repeat(MAX_ATTACHMENT_NAME_CHARS), | |
| 161 | + | repo_id: "repo_01m4z09g22e7ysqpg2ha423cws".into(), | |
| 162 | + | user_id: "usr_01m4z09g22e7ysqpg2ha423cws".into(), | |
| 163 | + | size: MAX_ATTACHMENT_BYTES as u64, | |
| 164 | + | at: 1_792_627_200_000, | |
| 165 | + | }; | |
| 166 | + | assert!(serde_json::to_string(&meta).unwrap().len() <= 1024); | |
| 167 | + | } | |
| 168 | + | } |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Comments
Sign in to comment.