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 + /// `