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