From 46bed5f26f0772a1890d54f4ed251450fc5d6998 Mon Sep 17 00:00:00 2001 From: Aditya Dhar <74172325+ProJedi1234@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:58:08 -0400 Subject: [PATCH] feat: attachment content types and the URL an attachment is embedded by MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server stores attachments now, and two facts about them are the library's to hold: which types a file can go up as, and where the bytes end up once it has. `attachmentsByExtension` is a second map rather than more rows in the first, because the two are read under opposite rules. An unknown extension on a page is a shrug that falls through to `text/html` — the case the tool is named for. On an upload the same shrug costs the upload: the bytes go up, the server validates them as UTF-8, and the 415 names a content type nobody chose. So `attachmentInferred` returns nil and the command refuses before spending the round trip. Sharing an entry point would have meant one of the two callers getting the other's answer. SVG is absent and should stay absent — it is the one image format that is also a document, the server does not accept it, and offering it here would turn a considered 415 into a type this tool appears to know. An attachment goes up through `publish`, which is not an omission: on the server it is a page whose body is bytes, same route, same namespace, and a second method here would invent a distinction the wire does not have. `filename` is the one parameter only an attachment may carry, and its ordinary failure is the silent one the `Query` enum exists for — `?flename=` earns a 201 and a download named after its slug. `bytesURL(for:)` is derived from the response rather than composed from `credential.host`, and that is the load-bearing decision. The server builds URLs from its own configured base, which need not be the address this client dialled; composing from the host we reached would hand back a URL that works from this machine and nowhere else, pasted into a page that outlives the machine. Scheme, host and port come from the server's answer and only the path is ours. It requires both rather than assuming them, because `URLComponents` parses `""` and a bare path quite happily and `/static/quiet-cedar-otter` looks enough like an answer to be printed. Co-Authored-By: Claude Opus 5 --- Sources/SteleKit/Inputs.swift | 39 +++++ Sources/SteleKit/SteleClient.swift | 73 +++++++++ Tests/SteleKitTests/InputsTests.swift | 62 ++++++++ Tests/SteleKitTests/SteleClientTests.swift | 166 +++++++++++++++++++++ 4 files changed, 340 insertions(+) diff --git a/Sources/SteleKit/Inputs.swift b/Sources/SteleKit/Inputs.swift index e31fba5..3344d22 100644 --- a/Sources/SteleKit/Inputs.swift +++ b/Sources/SteleKit/Inputs.swift @@ -34,6 +34,45 @@ public enum ContentType { let ext = (path as NSString).pathExtension.lowercased() return byExtension[ext] ?? fallback } + + /// Extension to type for the things `stele attach` uploads. + /// + /// A second map rather than more rows in `byExtension`, because the two are read under + /// opposite rules. Above, an unknown extension is a shrug that falls through to + /// `text/html`; here it has to be an error, so the two lookups cannot share an entry point + /// that returns a non-optional. + /// + /// SVG is absent deliberately and should stay absent. The server does not accept it — it is + /// the one image format that is also a document, script tags and all — and adding it here + /// would turn a considered `415` into a type this tool appears to offer. + static let attachmentsByExtension: [String: String] = [ + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "webp": "image/webp", + "mp4": "video/mp4", + "webm": "video/webm", + "pdf": "application/pdf", + ] + + /// What `stele attach --content-type` offers at TAB. + public static var knownAttachments: [String] { Set(attachmentsByExtension.values).sorted() } + + /// The type to upload a file as, or nil when the extension says nothing. + /// + /// Optional where `inferred` is not, and that difference is the whole point. `text/html` is + /// a *plausible* guess for an unnamed page and a catastrophic one for a JPEG: the bytes go + /// up, the server validates them as UTF-8, and the `415` that comes back names a + /// content type nobody chose. A nil here lets the command refuse before spending the + /// upload, in a message that can name `--content-type` as the way through. + /// + /// This is still not the allowlist. An explicit `--content-type` is passed through + /// untouched, whatever it says, so a deployment that learns a new type is usable from an + /// installed build the day it ships. + public static func attachmentInferred(fromPath path: String) -> String? { + attachmentsByExtension[(path as NSString).pathExtension.lowercased()] + } } /// A human-written lifetime — `90d`, `12h` — as the seconds `POST /admin/clients` wants. diff --git a/Sources/SteleKit/SteleClient.swift b/Sources/SteleKit/SteleClient.swift index c63fa09..d583463 100644 --- a/Sources/SteleKit/SteleClient.swift +++ b/Sources/SteleKit/SteleClient.swift @@ -61,7 +61,18 @@ public struct SteleClient: Sendable { public static let whoami = "/admin/whoami" public static let clients = "/admin/clients" + /// Where an attachment's bytes are served, with nothing rendered around them. + /// + /// The one path here that this client never sends a request to. It is a URL this tool + /// *prints* — the value that goes in an `` — and it lives with the others + /// because it is the same kind of fact: a segment agreed with another repository, whose + /// misspelling is a 404 in somebody's page rather than in a response we would see. + public static let staticFiles = "/static" + public static func page(slug: String) -> String { "\(pages)/\(segment(slug))" } + + /// One attachment's bytes, addressed by slug. + public static func bytes(slug: String) -> String { "\(staticFiles)/\(segment(slug))" } /// One credential, addressed by name — the handle every admin route uses. `GET` is not /// offered; `DELETE` here is what revokes. public static func client(named name: String) -> String { "\(clients)/\(segment(name))" } @@ -109,6 +120,16 @@ public struct SteleClient: Sendable { /// `PATCH /pages/:slug`. One word on the wire in both places, and a literal at each call /// site would be two strings that have to agree with each other and with the server. public static let slug = "slug" + + /// What a browser should save an attachment as. + /// + /// Attachments only, and the server says so: `?filename=` on a text page is a `400` + /// rather than a silent no-op, which makes this the one parameter in here whose + /// misspelling is *louder* than its absence — a typo lands in the general-purpose + /// silence this enum exists to prevent, but sending it where it does not belong is + /// caught. The failure to actually avoid is the ordinary one: `?flename=` on an + /// attachment is a download named after its slug, and a `201` either way. + public static let filename = "filename" } /// The type a page body is sent as when the caller expresses no opinion. @@ -132,17 +153,33 @@ public struct SteleClient: Sendable { /// takes the server's own default lifetime, and only `.never` asks for a page that is kept /// until it is deleted. The returned `PageLocation` carries the deadline the server settled /// on either way, which is the only place the answer exists. + /// + /// An attachment is published through this same method, and that is not an omission: on the + /// server an attachment is a page whose body is bytes, stored by the same route under the + /// same namespace, and a second method here would invent a distinction the wire does not + /// have. What decides which one you get is `contentType` — a type on the server's + /// attachment allowlist stores bytes, anything else is validated as text — and `filename`, + /// which only an attachment may carry. A text page sent with one earns a `400` naming the + /// parameter, rather than having it quietly dropped. + /// + /// The returned `PageLocation.url` is the *viewer* in both cases. For an attachment that is + /// a page about the file rather than the file, so a caller embedding the result in an + /// `` wants `bytesURL(for:)` instead — see the note there about which URL is which. public func publish( page: Data, contentType: String = SteleClient.defaultContentType, slug: String? = nil, ttl: PageTTL? = nil, + filename: String? = nil, using credential: Credential ) async throws -> PageLocation { var query = slug.map { [URLQueryItem(name: Query.slug, value: $0)] } ?? [] if let ttl { query.append(URLQueryItem(name: PageTTL.queryParameter, value: ttl.queryValue)) } + if let filename { + query.append(URLQueryItem(name: Query.filename, value: filename)) + } return try await send( method: "POST", path: Path.pages, @@ -155,6 +192,42 @@ public struct SteleClient: Sendable { ) } + /// Where an attachment's bytes are, given what publishing it answered. + /// + /// Every attachment has two URLs and they are not interchangeable. `location.url` is the + /// *viewer* — an HTML page about the file, with its name, size and deadline, which is what + /// you send a person. This is the file itself, which is what goes in an ``, a + /// `