diff --git a/juke-wizard/Sources/JukeWizard/JukeCloud.swift b/juke-wizard/Sources/JukeWizard/JukeCloud.swift new file mode 100644 index 0000000000..57ba1ca858 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/JukeCloud.swift @@ -0,0 +1,315 @@ +import AppKit +import Foundation +import UniformTypeIdentifiers + +struct JukeCloudTrack: Codable, Equatable { + let key: String + let name: String + let bytes: Int64 + let updatedAt: String? + let contentType: String? + let url: URL + let command: String +} + +private struct JukeCloudList: Codable { let tracks: [JukeCloudTrack] } +private struct JukeCloudPreparedUpload: Codable { + let uploadURL: URL + let headers: [String: String] + let track: JukeCloudTrack +} +private struct JukeCloudDownload: Codable { let url: URL } +private struct JukeCloudError: Codable { let error: String } + +enum JukeCloudClientError: LocalizedError { + case signedOut + case response(String) + + var errorDescription: String? { + switch self { + case .signedOut: return "Sign in to Aesthetic Computer first." + case .response(let message): return message + } + } +} + +final class JukeCloudClient { + private let session: URLSession + private let endpoint: URL + + init(session: URLSession = .shared) { + self.session = session + let origin = ProcessInfo.processInfo.environment["AC_API_ORIGIN"] + ?? "https://aesthetic.computer" + self.endpoint = URL(string: origin.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + + "/api/juke-cloud")! + } + + private func request(_ method: String = "GET", body: [String: Any]? = nil) throws -> URLRequest { + guard let token = ACSession.shared.token() else { throw JukeCloudClientError.signedOut } + var request = URLRequest(url: endpoint) + request.httpMethod = method + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + } + return request + } + + private func decode(_ type: T.Type, data: Data, response: URLResponse) throws -> T { + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { + let message = (try? JSONDecoder().decode(JukeCloudError.self, from: data).error) + ?? "Cloud request failed (\(status))." + throw JukeCloudClientError.response(message) + } + return try JSONDecoder().decode(type, from: data) + } + + func list() async throws -> [JukeCloudTrack] { + let (data, response) = try await session.data(for: request()) + return try decode(JukeCloudList.self, data: data, response: response).tracks + } + + func upload(file: URL) async throws -> JukeCloudTrack { + let values = try file.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + guard values.isRegularFile == true, let bytes = values.fileSize, bytes > 0 else { + throw JukeCloudClientError.response("Choose a non-empty audio file.") + } + let preparedRequest = try request("POST", body: [ + "action": "upload", "filename": file.lastPathComponent, "bytes": bytes, + ]) + let (preparedData, preparedResponse) = try await session.data(for: preparedRequest) + let prepared = try decode(JukeCloudPreparedUpload.self, + data: preparedData, response: preparedResponse) + var upload = URLRequest(url: prepared.uploadURL) + upload.httpMethod = "PUT" + for (name, value) in prepared.headers { upload.setValue(value, forHTTPHeaderField: name) } + upload.setValue(String(bytes), forHTTPHeaderField: "Content-Length") + let (_, response) = try await session.upload(for: upload, fromFile: file) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { + throw JukeCloudClientError.response("Upload failed (\(status)).") + } + return prepared.track + } + + func download(_ track: JukeCloudTrack) async throws -> URL { + let signedRequest = try request("POST", body: ["action": "download", "key": track.key]) + let (signedData, signedResponse) = try await session.data(for: signedRequest) + let signed = try decode(JukeCloudDownload.self, data: signedData, response: signedResponse) + let (temporary, response) = try await session.download(from: signed.url) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { + throw JukeCloudClientError.response("Download failed (\(status)).") + } + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Aesthetic Computer/JukeWizard/Cloud", isDirectory: true) + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + let destination = base.appendingPathComponent("\(UUID().uuidString)-\(track.name)") + try FileManager.default.moveItem(at: temporary, to: destination) + return destination + } +} + +final class JukeCloudWindowController: NSWindowController, + NSTableViewDataSource, NSTableViewDelegate { + private let client = JukeCloudClient() + private let currentFile: () -> URL? + private let loadLocalFile: (URL) -> Void + private var sessionWatch: UUID? + private var tracks: [JukeCloudTrack] = [] + private let account = NSTextField(labelWithString: "") + private let status = NSTextField(labelWithString: "") + private let signIn = NSButton() + private let uploadCurrent = NSButton() + private let uploadOther = NSButton() + private let refreshButton = NSButton() + private let loadButton = NSButton() + private let copyButton = NSButton() + private let table = NSTableView() + private let scroll = NSScrollView() + + init(currentFile: @escaping () -> URL?, loadLocalFile: @escaping (URL) -> Void) { + self.currentFile = currentFile + self.loadLocalFile = loadLocalFile + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 580, height: 390), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.title = "Juke Cloud" + window.minSize = NSSize(width: 480, height: 300) + window.center() + super.init(window: window) + buildUI() + sessionWatch = ACSession.shared.startWatching { [weak self] in self?.sessionChanged() } + } + + required init?(coder: NSCoder) { fatalError() } + deinit { if let sessionWatch { ACSession.shared.stopWatching(sessionWatch) } } + + func prepareForDisplay() { sessionChanged() } + + private func buildUI() { + guard let content = window?.contentView else { return } + account.font = .systemFont(ofSize: 13, weight: .semibold) + account.frame = NSRect(x: 14, y: 352, width: 230, height: 22) + account.autoresizingMask = [.maxXMargin, .minYMargin] + content.addSubview(account) + + configure(signIn, "Sign in", #selector(signInAction)) + configure(uploadCurrent, "Upload playing", #selector(uploadCurrentAction)) + configure(uploadOther, "Upload…", #selector(uploadOtherAction)) + configure(refreshButton, "Refresh", #selector(refreshAction)) + signIn.frame = NSRect(x: 240, y: 347, width: 78, height: 28) + uploadCurrent.frame = NSRect(x: 322, y: 347, width: 110, height: 28) + uploadOther.frame = NSRect(x: 436, y: 347, width: 72, height: 28) + refreshButton.frame = NSRect(x: 512, y: 347, width: 58, height: 28) + for button in [signIn, uploadCurrent, uploadOther, refreshButton] { + button.autoresizingMask = [.minXMargin, .minYMargin] + content.addSubview(button) + } + + let name = NSTableColumn(identifier: .init("name")) + name.title = "Track"; name.width = 350 + let size = NSTableColumn(identifier: .init("size")) + size.title = "Size"; size.width = 90 + table.addTableColumn(name); table.addTableColumn(size) + table.dataSource = self; table.delegate = self + table.target = self; table.doubleAction = #selector(loadAction) + table.usesAlternatingRowBackgroundColors = true + scroll.documentView = table + scroll.hasVerticalScroller = true + scroll.frame = NSRect(x: 12, y: 54, width: 556, height: 286) + scroll.autoresizingMask = [.width, .height] + content.addSubview(scroll) + + configure(loadButton, "Load in JukeWizard", #selector(loadAction)) + configure(copyButton, "Copy play command", #selector(copyAction)) + loadButton.frame = NSRect(x: 12, y: 12, width: 144, height: 30) + copyButton.frame = NSRect(x: 160, y: 12, width: 138, height: 30) + status.frame = NSRect(x: 306, y: 17, width: 262, height: 20) + status.alignment = .right + status.lineBreakMode = .byTruncatingTail + status.textColor = .secondaryLabelColor + status.autoresizingMask = [.width, .maxYMargin] + content.addSubview(loadButton); content.addSubview(copyButton); content.addSubview(status) + updateSelection() + } + + private func configure(_ button: NSButton, _ title: String, _ action: Selector) { + button.title = title; button.target = self; button.action = action + button.bezelStyle = .rounded + } + + private func sessionChanged() { + let signedIn = ACSession.shared.token() != nil + account.stringValue = ACSession.shared.displayName.map { "☁︎ \($0)" } ?? "Juke Cloud" + signIn.isHidden = signedIn + uploadCurrent.isEnabled = signedIn && currentFile() != nil + uploadOther.isEnabled = signedIn + refreshButton.isEnabled = signedIn + if signedIn { refresh() } + else { + tracks = []; table.reloadData(); status.stringValue = "Sign in to sync tracks" + updateSelection() + } + } + + private func refresh() { + status.stringValue = "Loading…" + Task { [weak self] in + guard let self else { return } + do { + let tracks = try await client.list() + await MainActor.run { + self.tracks = tracks; self.table.reloadData() + self.status.stringValue = tracks.isEmpty ? "No cloud tracks" : "\(tracks.count) cloud track\(tracks.count == 1 ? "" : "s")" + self.updateSelection() + } + } catch { await MainActor.run { self.status.stringValue = error.localizedDescription } } + } + } + + private func upload(_ file: URL) { + status.stringValue = "Uploading \(file.lastPathComponent)…" + uploadCurrent.isEnabled = false; uploadOther.isEnabled = false + Task { [weak self] in + guard let self else { return } + do { + _ = try await client.upload(file: file) + await MainActor.run { + self.uploadOther.isEnabled = true + self.uploadCurrent.isEnabled = self.currentFile() != nil + self.refresh() + } + } catch { + await MainActor.run { + self.status.stringValue = error.localizedDescription + self.uploadOther.isEnabled = true + self.uploadCurrent.isEnabled = self.currentFile() != nil + } + } + } + } + + private var selected: JukeCloudTrack? { + let row = table.selectedRow + return row >= 0 && row < tracks.count ? tracks[row] : nil + } + + private func updateSelection() { + let enabled = selected != nil + loadButton.isEnabled = enabled; copyButton.isEnabled = enabled + } + + func numberOfRows(in tableView: NSTableView) -> Int { tracks.count } + func tableViewSelectionDidChange(_ notification: Notification) { updateSelection() } + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let track = tracks[row] + let field = NSTextField(labelWithString: tableColumn?.identifier.rawValue == "size" + ? ByteCountFormatter.string(fromByteCount: track.bytes, countStyle: .file) + : track.name) + field.lineBreakMode = .byTruncatingMiddle + return field + } + + @objc private func signInAction() { + status.stringValue = "Opening browser…" + ACLogin.shared.signIn { [weak self] result in + if case .failure(let error) = result { self?.status.stringValue = error.localizedDescription } + else { self?.sessionChanged() } + } + } + + @objc private func uploadCurrentAction() { if let file = currentFile() { upload(file) } } + @objc private func uploadOtherAction() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true; panel.canChooseDirectories = false + panel.allowedContentTypes = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "aif", "aiff", "caf"] + .compactMap { UTType(filenameExtension: $0) } + guard panel.runModal() == .OK else { return } + for file in panel.urls { upload(file) } + } + @objc private func refreshAction() { refresh() } + @objc private func loadAction() { + guard let selected else { return } + status.stringValue = "Downloading \(selected.name)…" + Task { [weak self] in + guard let self else { return } + do { + let file = try await client.download(selected) + await MainActor.run { + self.loadLocalFile(file) + self.status.stringValue = "Loaded \(selected.name)" + } + } catch { await MainActor.run { self.status.stringValue = error.localizedDescription } } + } + } + @objc private func copyAction() { + guard let selected else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(selected.command, forType: .string) + status.stringValue = "Copied for aesthetic.computer" + } +} diff --git a/juke-wizard/Sources/JukeWizard/JukeController.swift b/juke-wizard/Sources/JukeWizard/JukeController.swift index a14eeccb83..17fca3e4ab 100644 --- a/juke-wizard/Sources/JukeWizard/JukeController.swift +++ b/juke-wizard/Sources/JukeWizard/JukeController.swift @@ -106,6 +106,8 @@ final class JukeController: NSWindowController, NSWindowDelegate, var ledLabel: NSTextField! var notesToggle: NSButton! var roomButton: NSButton! + var cloudButton: NSButton! + var cloudWindow: JukeCloudWindowController? var roomPopover: NSPopover? var roomMixer: RoomMixerView? var miniPopover: NSPopover? @@ -355,6 +357,12 @@ final class JukeController: NSWindowController, NSWindowDelegate, appearanceTabs.toolTip = "Follow macOS, or pin JukeWizard to light or dark" content.addSubview(appearanceTabs) + cloudButton = NSButton(title: "☁︎", target: self, action: #selector(showCloud)) + cloudButton.bezelStyle = .rounded + cloudButton.contentTintColor = Palette.teal + cloudButton.toolTip = "Sign in and sync tracks with Juke Cloud" + content.addSubview(cloudButton) + outputPopup = AudioOutputPopUpButton(frame: .zero, pullsDown: false) outputPopup.controlSize = .small outputPopup.bezelStyle = .rounded @@ -522,7 +530,9 @@ final class JukeController: NSWindowController, NSWindowDelegate, sourceTabs.frame = NSRect(x: pad, y: H - 27, width: 170, height: 22) appearanceTabs.frame = NSRect(x: W - pad - 172, y: H - 27, width: 172, height: 22) let outputX = pad + 178 - let outputRight = appearanceTabs.frame.minX - 8 + cloudButton.frame = NSRect(x: appearanceTabs.frame.minX - 50, y: H - 28, + width: 44, height: 24) + let outputRight = cloudButton.frame.minX - 6 outputPopup.frame = NSRect(x: outputX, y: H - 27, width: max(110, min(260, outputRight - outputX)), height: 22) // ── header (now-playing) across the top ─────────────────────────────── @@ -982,6 +992,34 @@ final class JukeController: NSWindowController, NSWindowDelegate, return menu } + @objc private func showCloud() { + if cloudWindow == nil { + cloudWindow = JukeCloudWindowController( + currentFile: { [weak self] in self?.track?.url }, + loadLocalFile: { [weak self] in self?.loadCloudFile($0) }) + } + cloudWindow?.prepareForDisplay() + cloudWindow?.showWindow(nil) + cloudWindow?.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + private func loadCloudFile(_ url: URL) { + if let index = library.tracks.firstIndex(where: { + $0.url.standardizedFileURL == url.standardizedFileURL + }) { + select(index, autoplay: true) + return + } + library.addFile(url, lane: "cloud") + listTable.reloadData() + if let index = library.tracks.firstIndex(where: { + $0.url.standardizedFileURL == url.standardizedFileURL + }) { + select(index, autoplay: true) + } + } + // ── selection / playback ────────────────────────────────────────────── private var track: Track? { (current >= 0 && current < library.tracks.count) ? library.tracks[current] : nil } diff --git a/juke-wizard/Tests/juke-cloud.test.mjs b/juke-wizard/Tests/juke-cloud.test.mjs new file mode 100644 index 0000000000..aa1ed57d68 --- /dev/null +++ b/juke-wizard/Tests/juke-cloud.test.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { audioType, ownsKey, safeTrackName, userPrefix } from "../../system/netlify/functions/juke-cloud.mjs"; + +test("track names cannot escape the Juke prefix", () => { + assert.equal(safeTrackName("../../mix ? 4.wav"), "mix - 4.wav"); + assert.equal(userPrefix("auth0|one"), "auth0|one/jukewizard/"); + assert.equal(ownsKey("auth0|one", "auth0|one/jukewizard/id-mix.wav"), true); + assert.equal(ownsKey("auth0|one", "auth0|two/jukewizard/id-mix.wav"), false); + assert.equal(ownsKey("auth0|one", "auth0|one/jukewizard/../private.wav"), false); +}); + +test("only audio extensions receive content types", () => { + assert.equal(audioType("demo.MP3"), "audio/mpeg"); + assert.equal(audioType("demo.aiff"), "audio/aiff"); + assert.equal(audioType("demo.json"), null); +}); diff --git a/juke-wizard/bin/juke-cloud.mjs b/juke-wizard/bin/juke-cloud.mjs new file mode 100644 index 0000000000..353110dfe6 --- /dev/null +++ b/juke-wizard/bin/juke-cloud.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +import { createReadStream, createWriteStream, promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { basename, resolve } from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +const origin = (process.env.AC_API_ORIGIN || "https://aesthetic.computer").replace(/\/$/, ""); +const endpoint = `${origin}/api/juke-cloud`; + +function usage() { + console.log(`usage: + jukewizard login + jukewizard cloud list [--json] + jukewizard cloud push [...] + jukewizard cloud pull [destination] + jukewizard cloud url `); +} + +async function session() { + let value; + try { value = JSON.parse(await fs.readFile(`${homedir()}/.ac-token`, "utf8")); } + catch { throw new Error("Not signed in. Run: jukewizard login"); } + if (!value.access_token) throw new Error("Not signed in. Run: jukewizard login"); + if (value.expires_at && value.expires_at <= Date.now()) { + throw new Error("Your Aesthetic Computer login expired. Run: jukewizard login"); + } + return value.access_token; +} + +async function api(method = "GET", input) { + const token = await session(); + const response = await fetch(endpoint, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(input ? { "Content-Type": "application/json" } : {}), + }, + body: input ? JSON.stringify(input) : undefined, + }); + let output = {}; + try { output = await response.json(); } catch {} + if (!response.ok) throw new Error(output.error || `Cloud request failed (${response.status})`); + return output; +} + +async function list() { + return (await api()).tracks || []; +} + +async function push(path) { + const absolute = resolve(path); + const stat = await fs.stat(absolute); + if (!stat.isFile()) throw new Error(`${path} is not a file`); + const prepared = await api("POST", { + action: "upload", + filename: basename(absolute), + bytes: stat.size, + }); + const response = await fetch(prepared.uploadURL, { + method: "PUT", + headers: { ...prepared.headers, "Content-Length": String(stat.size) }, + body: Readable.toWeb(createReadStream(absolute)), + duplex: "half", + }); + if (!response.ok) throw new Error(`Upload failed (${response.status})`); + return prepared.track; +} + +function suggestedName(key) { + return key.split("/").pop().replace(/^[0-9a-f-]{36}-/, ""); +} + +async function pull(key, destination) { + const { url } = await api("POST", { action: "download", key }); + const response = await fetch(url); + if (!response.ok || !response.body) throw new Error(`Download failed (${response.status})`); + const path = resolve(destination || suggestedName(key)); + await pipeline(Readable.fromWeb(response.body), createWriteStream(path, { flags: "wx" })); + return path; +} + +async function main() { + const [command, ...args] = process.argv.slice(2); + if (command === "list") { + const tracks = await list(); + if (args.includes("--json")) return console.log(JSON.stringify({ tracks }, null, 2)); + if (!tracks.length) return console.log("No cloud tracks yet."); + for (const track of tracks) { + console.log(`${track.name}\t${track.bytes} bytes\n ${track.key}\n ${track.command}`); + } + return; + } + if (command === "push") { + if (!args.length) throw new Error("Choose at least one audio file."); + for (const path of args) { + const track = await push(path); + console.log(`uploaded ${track.name}\n ${track.key}\n ${track.command}`); + } + return; + } + if (command === "pull") { + if (!args[0]) throw new Error("Provide the cloud key shown by `jukewizard cloud list`."); + console.log(await pull(args[0], args[1])); + return; + } + if (command === "url") { + if (!args[0]) throw new Error("Provide the cloud key shown by `jukewizard cloud list`."); + const track = (await list()).find((item) => item.key === args[0]); + if (!track) throw new Error("Cloud track not found."); + console.log(track.url); + return; + } + usage(); + if (command && command !== "help" && command !== "--help") process.exitCode = 2; +} + +main().catch((error) => { + console.error(`jukewizard: ${error.message}`); + process.exitCode = 1; +}); diff --git a/juke-wizard/bin/jukewizard b/juke-wizard/bin/jukewizard index 837fa65a44..708f4f212e 100755 --- a/juke-wizard/bin/jukewizard +++ b/juke-wizard/bin/jukewizard @@ -20,6 +20,20 @@ set -e HERE="$(cd "$(dirname "$0")" && pwd)" WIZ="$HERE/.." REPO="$(cd "$WIZ/.." && pwd)" + +if [ "${1:-}" = "login" ]; then + shift + exec ac-login "$@" +fi +if [ "${1:-}" = "cloud" ]; then + shift + exec node "$HERE/juke-cloud.mjs" "$@" +fi +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "help" ]; then + node "$HERE/juke-cloud.mjs" --help + exit 0 +fi + swift build -c release --package-path "$WIZ" 2>&1 | grep -v '^$' | tail -2 || true BIN="$WIZ/.build/release/JukeWizard" diff --git a/juke-wizard/bin/jukewizard-installed b/juke-wizard/bin/jukewizard-installed index c24cd58051..fa462e05e0 100755 --- a/juke-wizard/bin/jukewizard-installed +++ b/juke-wizard/bin/jukewizard-installed @@ -2,4 +2,16 @@ # Stable launcher for a JukeWizard installed by ../install.sh. set -eu INSTALL_ROOT="${JUKEWIZARD_HOME:-$HOME/.local/lib/jukewizard}" +if [ "${1:-}" = "login" ]; then + shift + exec ac-login "$@" +fi +if [ "${1:-}" = "cloud" ]; then + shift + exec node "$INSTALL_ROOT/juke-cloud.mjs" "$@" +fi +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "help" ]; then + node "$INSTALL_ROOT/juke-cloud.mjs" --help + exit 0 +fi exec "$INSTALL_ROOT/JukeWizard" "$@" diff --git a/juke-wizard/install.sh b/juke-wizard/install.sh index 063f80a839..95243e5a0c 100755 --- a/juke-wizard/install.sh +++ b/juke-wizard/install.sh @@ -10,8 +10,8 @@ LAUNCH_AGENT_DIR="$HOME/Library/LaunchAgents" LAUNCH_AGENT="$LAUNCH_AGENT_DIR/$LAUNCH_LABEL.plist" LOG_DIR="$HOME/Library/Logs" -/usr/bin/swift build -c release --package-path "$ROOT" -BUILD_BIN="$(/usr/bin/swift build -c release --package-path "$ROOT" --show-bin-path)" +swift build -c release --package-path "$ROOT" +BUILD_BIN="$(swift build -c release --package-path "$ROOT" --show-bin-path)" BUNDLE="$BUILD_BIN/JukeWizard_JukeWizard.bundle" test -x "$BUILD_BIN/JukeWizard" @@ -19,6 +19,7 @@ test -d "$BUNDLE" /bin/mkdir -p "$INSTALL_ROOT" "$BIN_DIR" /usr/bin/install -m 0755 "$BUILD_BIN/JukeWizard" "$INSTALL_ROOT/JukeWizard" /usr/bin/ditto "$BUNDLE" "$INSTALL_ROOT/JukeWizard_JukeWizard.bundle" +/usr/bin/install -m 0755 "$ROOT/bin/juke-cloud.mjs" "$INSTALL_ROOT/juke-cloud.mjs" /usr/bin/install -m 0755 "$ROOT/bin/jukewizard-installed" "$BIN_DIR/jukewizard" # Own the resident menu-bar process with the user's Aqua launchd session. diff --git a/system/netlify/functions/juke-cloud.mjs b/system/netlify/functions/juke-cloud.mjs new file mode 100644 index 0000000000..8a1e70718f --- /dev/null +++ b/system/netlify/functions/juke-cloud.mjs @@ -0,0 +1,172 @@ +// Authenticated cloud storage for JukeWizard audio. + +import { randomUUID } from "node:crypto"; +import { + GetObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { authorize } from "../../backend/authorization.mjs"; +import { respond } from "../../backend/http.mjs"; + +const AUDIO_TYPES = new Map([ + ["mp3", "audio/mpeg"], + ["wav", "audio/wav"], + ["flac", "audio/flac"], + ["ogg", "audio/ogg"], + ["m4a", "audio/mp4"], + ["aac", "audio/aac"], + ["aif", "audio/aiff"], + ["aiff", "audio/aiff"], + ["caf", "audio/x-caf"], +]); +const MAX_TRACK_BYTES = 2 * 1024 * 1024 * 1024; +let s3; + +export function safeTrackName(value) { + const leaf = String(value || "").split(/[\\/]/).pop() || ""; + return leaf.normalize("NFKC") + .replace(/[\u0000-\u001f\u007f]/g, "") + .replace(/[^\p{L}\p{N} ._()\[\]-]+/gu, "-") + .replace(/\s+/g, " ") + .replace(/^\.+/, "") + .trim() + .slice(0, 180); +} + +export function audioType(filename) { + const extension = safeTrackName(filename).split(".").pop()?.toLowerCase(); + return extension ? AUDIO_TYPES.get(extension) || null : null; +} + +export function userPrefix(sub) { + return `${sub}/jukewizard/`; +} + +export function ownsKey(sub, key) { + return typeof key === "string" && key.startsWith(userPrefix(sub)) && !key.includes("../"); +} + +function storageConfig() { + const accessKeyId = process.env.ART_KEY || process.env.DO_SPACES_KEY; + const secretAccessKey = process.env.ART_SECRET || process.env.DO_SPACES_SECRET; + const endpointName = process.env.USER_ENDPOINT || process.env.ART_ENDPOINT || "sfo3.digitaloceanspaces.com"; + const endpoint = endpointName.startsWith("http") ? endpointName : `https://${endpointName}`; + const bucket = process.env.USER_SPACE_NAME || "user-aesthetic-computer"; + if (!accessKeyId || !secretAccessKey) throw new Error("Juke cloud storage is unavailable"); + return { accessKeyId, secretAccessKey, endpoint, bucket }; +} + +function client() { + if (s3) return s3; + const config = storageConfig(); + s3 = new S3Client({ + endpoint: config.endpoint, + region: "us-east-1", + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + requestChecksumCalculation: "WHEN_REQUIRED", + responseChecksumValidation: "WHEN_REQUIRED", + }); + return s3; +} + +function publicURL(key) { + const { endpoint, bucket } = storageConfig(); + const host = new URL(endpoint).host; + const encoded = key.split("/").map(encodeURIComponent).join("/"); + return `https://${bucket}.${host}/${encoded}`; +} + +function trackFromObject(object) { + const key = object.Key || ""; + const storedName = key.slice(key.lastIndexOf("/") + 1); + const name = storedName.replace(/^[0-9a-f-]{36}-/, ""); + const url = publicURL(key); + return { + key, + name, + bytes: object.Size || 0, + updatedAt: object.LastModified?.toISOString?.() || null, + etag: object.ETag?.replaceAll('"', "") || null, + contentType: audioType(name), + url, + command: `play ${url}`, + }; +} + +function body(event) { + if (!event.body) return {}; + try { return JSON.parse(event.body); } + catch { return null; } +} + +async function authenticated(event) { + const user = await authorize(event.headers || {}); + return user?.sub ? user : null; +} + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") return respond(204, ""); + try { + const user = await authenticated(event); + if (!user) return respond(401, { error: "Sign in to use Juke cloud." }); + const { bucket } = storageConfig(); + + if (event.httpMethod === "GET") { + const listed = await client().send(new ListObjectsV2Command({ + Bucket: bucket, + Prefix: userPrefix(user.sub), + MaxKeys: 1000, + })); + const tracks = (listed.Contents || []) + .filter((object) => object.Key && audioType(object.Key)) + .map(trackFromObject) + .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))); + return respond(200, { tracks, truncated: !!listed.IsTruncated }); + } + + if (event.httpMethod !== "POST") { + return respond(405, { error: "Method not allowed." }); + } + + const input = body(event); + if (!input) return respond(400, { error: "Invalid JSON." }); + + if (input.action === "download") { + if (!ownsKey(user.sub, input.key)) return respond(403, { error: "Track is outside your cloud library." }); + const url = await getSignedUrl(client(), new GetObjectCommand({ + Bucket: bucket, + Key: input.key, + ResponseContentDisposition: `attachment; filename="${safeTrackName(input.key)}"`, + }), { expiresIn: 15 * 60 }); + return respond(200, { url }); + } + + if (input.action !== "upload") return respond(400, { error: "Unknown action." }); + const filename = safeTrackName(input.filename); + const contentType = audioType(filename); + const bytes = Number(input.bytes); + if (!filename || !contentType) return respond(400, { error: "Choose an MP3, WAV, FLAC, OGG, M4A, AAC, AIFF, or CAF file." }); + if (!Number.isSafeInteger(bytes) || bytes <= 0 || bytes > MAX_TRACK_BYTES) { + return respond(400, { error: "Track size must be between 1 byte and 2 GB." }); + } + const key = `${userPrefix(user.sub)}${randomUUID()}-${filename}`; + const uploadURL = await getSignedUrl(client(), new PutObjectCommand({ + Bucket: bucket, + Key: key, + ContentType: contentType, + ContentDisposition: "inline", + ACL: "public-read", + }), { expiresIn: 15 * 60 }); + const track = trackFromObject({ Key: key, Size: bytes, LastModified: new Date() }); + return respond(200, { uploadURL, headers: { "Content-Type": contentType }, track }); + } catch (error) { + console.error("juke-cloud failed", error?.message || error); + return respond(503, { error: "Juke cloud is temporarily unavailable." }); + } +}