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
39 changes: 39 additions & 0 deletions Sources/SteleKit/Inputs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
73 changes: 73 additions & 0 deletions Sources/SteleKit/SteleClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img src>` — 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))" }
Expand Down Expand Up @@ -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.
Expand All @@ -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
/// `<img src>` 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,
Expand All @@ -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 `<img src>`, a
/// `<video src>` or a download link. Getting them the wrong way round fails silently: an
/// `<img>` pointed at the viewer renders nothing, and both URLs answer `200`.
///
/// Derived from the response rather than composed from `credential.host`, which is the same
/// rule `amend` states about never rebuilding an address from the arguments, for a sharper
/// reason here. The server builds its URLs from its own configured base, and the address
/// this client happens to reach it on need not be that base — a deployment behind a proxy,
/// or reached over a tailnet name, answers with the public one. Composing from the host we
/// dialled would hand back a URL that works from this machine and from nowhere else, pasted
/// into a page that outlives the machine. So scheme, host and port come from the server's
/// own answer, and only the path is ours.
///
/// Nil when the server's `url` is not an absolute URL, which is a server answering strangely
/// rather than anything a caller did — `SteleError.malformedResponse` is the shape that
/// fits. A scheme and a host are required rather than assumed, and that guard is the whole
/// of the check worth making: `URLComponents` parses `""` and a bare path quite happily, so
/// a viewer URL missing its front would come back here as `/static/quiet-cedar-otter` — a
/// string that looks enough like an answer to be printed, pasted into a page, and to
/// resolve against whatever host that page is served from.
public static func bytesURL(for location: PageLocation) -> String? {
guard var components = URLComponents(string: location.url),
components.scheme != nil, components.host != nil
else { return nil }
components.percentEncodedPath = Path.bytes(slug: location.slug)
// The viewer carries neither, but a base URL configured with one would, and neither
// belongs on the bytes.
components.query = nil
components.fragment = nil
return components.url?.absoluteString
}

/// `PUT /pages/:slug`. Replaces a page that already exists; never creates one.
///
/// Takes no `ttl`, and not by omission — the server answers `?ttl=` on this verb with a
Expand Down
62 changes: 62 additions & 0 deletions Tests/SteleKitTests/InputsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,68 @@ struct InputsTests {
#expect(ContentType.inferred(fromPath: "archive.tar.gz") == ContentType.fallback)
}

@Test(
"an attachment's type comes from its extension",
arguments: [
("screenshot.png", "image/png"),
("photo.jpg", "image/jpeg"),
("photo.jpeg", "image/jpeg"),
("loop.gif", "image/gif"),
("shot.webp", "image/webp"),
("clip.mp4", "video/mp4"),
("clip.webm", "video/webm"),
("report.pdf", "application/pdf"),
]
)
func attachmentInference(_ path: String, _ expected: String) {
#expect(ContentType.attachmentInferred(fromPath: path) == expected)
}

@Test("attachment inference ignores case and leading directories")
func attachmentInferenceIsForgiving() {
#expect(ContentType.attachmentInferred(fromPath: "~/shots/SCREEN.PNG") == "image/png")
#expect(ContentType.attachmentInferred(fromPath: "/tmp/a.b.c/clip.MP4") == "video/mp4")
}

/// The inversion of `inferenceFallsThrough`, and the reason the two maps are separate. A
/// guess costs nothing on a page — `text/html` is what the tool is named for. On an upload
/// it costs the upload: the bytes go up, the server validates them as UTF-8 and answers
/// `415` naming a type nobody chose. Nil is what lets the command refuse first.
@Test("an unknown extension has no attachment type rather than a default one")
func attachmentInferenceRefusesToGuess() {
#expect(ContentType.attachmentInferred(fromPath: "screenshot") == nil)
#expect(ContentType.attachmentInferred(fromPath: "archive.tar.gz") == nil)
}

/// The two maps do not overlap, and neither is a superset of the other. A `.png` is not a
/// page and an `.html` is not an attachment, so each lookup declines the other's files in
/// the way its own caller can act on.
@Test("pages and attachments keep their own tables")
func theTablesStaySeparate() {
#expect(ContentType.attachmentInferred(fromPath: "page.html") == nil)
#expect(ContentType.inferred(fromPath: "screenshot.png") == ContentType.fallback)
}

/// SVG 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.
@Test("svg is not an attachment type this tool offers")
func svgIsNotOffered() {
#expect(ContentType.attachmentInferred(fromPath: "diagram.svg") == nil)
#expect(!ContentType.knownAttachments.contains("image/svg+xml"))
}

/// What TAB offers, deduplicated: two extensions map to `image/jpeg` and completing it
/// twice would read as two different types.
@Test("the completion list is the distinct attachment types, sorted")
func knownAttachmentsAreDistinct() {
#expect(
ContentType.knownAttachments == [
"application/pdf", "image/gif", "image/jpeg", "image/png", "image/webp",
"video/mp4", "video/webm",
]
)
}

/// Spelled out as a typed constant rather than inline in the `@Test` attribute: the macro
/// expands its `arguments:` into a generic call the type checker gives up on when the
/// literals need inference *and* arithmetic. The trailing case is a reminder that the
Expand Down
Loading