diff --git a/package-lock.json b/package-lock.json index 9f4118b..4d095cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ssntpl/otper-cli", - "version": "0.1.7", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ssntpl/otper-cli", - "version": "0.1.7", + "version": "0.1.8", "license": "MIT", "dependencies": { "@oclif/core": "^4.0.30", diff --git a/package.json b/package.json index 0511a98..addcff7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ssntpl/otper-cli", - "version": "0.1.7", + "version": "0.1.8", "description": "Command-line interface for Otper boards (https://otper.com).", "author": "SSNTPL ", "license": "MIT", diff --git a/src/api/files.ts b/src/api/files.ts new file mode 100644 index 0000000..e85737c --- /dev/null +++ b/src/api/files.ts @@ -0,0 +1,173 @@ +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +import { OtperClient, OtperApiError, GraphQLError } from './client'; + +/** + * Otper file uploads ride on the apollo-upload-client multipart spec + * against the GraphQL `upload` mutation. Two non-obvious requirements: + * + * 1. The `X-Requested-With: XMLHttpRequest` header is mandatory — + * Lighthouse's EnsureXHR middleware rejects multipart POSTs without it. + * 2. `owner.connect.type` must be the literal Eloquent class FQN with + * single backslashes (e.g. `App\Models\Card`). The resolver matches + * with a `switch` on `Card::class`, so any other form falls through. + * + * The REST endpoint `POST /api/v1/.../fileUp` exists but is gated by an + * ability that personal tokens lack — GraphQL is the only path that works. + */ + +const OWNER_FQN: Record = { + Card: 'App\\Models\\Card', + Comment: 'App\\Models\\Comment', + UserActivity: 'App\\Models\\UserActivity', +}; + +const UPLOAD_MUTATION = /* GraphQL */ ` + mutation Upload( + $file: Upload! + $type: String! + $disk: String! + $card_id: ID! + $owner: OwnerBelongsToInput! + ) { + upload( + input: { + file: $file + type: $type + disk: $disk + card_id: $card_id + owner: $owner + } + ) + } +`; + +export type OwnerType = 'Card' | 'Comment' | 'UserActivity'; + +export interface UploadedFile { + id: number | string; + type: string; + name: string; + key: string; + disk: string; + size: number; + checksum: string | null; + created_at?: string; + updated_at?: string; + owner?: unknown; +} + +export interface UploadInput { + /** ID of the card the file belongs to (required even when attaching to a comment). */ + cardId: string | number; + /** What the file is attached to. Defaults to `Card`. */ + ownerType?: OwnerType; + /** ID of the owner record (comment id, activity id). Defaults to `cardId` when ownerType is Card. */ + ownerId?: string | number; + /** Local file path. */ + filePath: string; + /** Override the filename sent to the server. Defaults to basename(filePath). */ + filename?: string; + /** Override MIME type. Defaults to a small extension-based mapping. */ + mimeType?: string; + /** Storage disk; the resolver ignores the value but the schema requires non-empty. */ + disk?: string; +} + +const MIME_BY_EXT: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.md': 'text/markdown', + '.csv': 'text/csv', + '.json': 'application/json', + '.zip': 'application/zip', + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', + '.mp3': 'audio/mpeg', +}; + +function detectMime(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + return MIME_BY_EXT[ext] ?? 'application/octet-stream'; +} + +/** + * Upload a file and attach it to a card (or to a comment / user activity + * recorded against a card). Returns the resolver's JSON payload. + */ +export async function uploadFile( + client: OtperClient, + input: UploadInput, +): Promise { + const ownerType = input.ownerType ?? 'Card'; + const ownerFqn = OWNER_FQN[ownerType]; + if (!ownerFqn) throw new Error(`Unsupported owner type: ${ownerType}`); + + const cardId = String(input.cardId); + const ownerId = String(input.ownerId ?? cardId); + const buf = await fs.readFile(input.filePath); + const filename = input.filename ?? path.basename(input.filePath); + const mimeType = input.mimeType ?? detectMime(input.filePath); + const disk = input.disk ?? 's3'; + + const operations = { + query: UPLOAD_MUTATION, + variables: { + file: null, + type: ownerType, + disk, + card_id: cardId, + owner: { connect: { id: ownerId, type: ownerFqn } }, + }, + }; + const map = { '0': ['variables.file'] }; + + const form = new FormData(); + form.append('operations', JSON.stringify(operations)); + form.append('map', JSON.stringify(map)); + form.append('0', new Blob([buf], { type: mimeType }), filename); + + const url = `${client.baseUrl}/graphql`; + if (client.debug) { + // eslint-disable-next-line no-console + console.error(`[otper] POST (multipart) ${url} file=${filename} (${buf.length} bytes)`); + } + const res = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${client.token}`, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: form, + }); + const text = await res.text(); + let parsed: { data?: { upload: UploadedFile }; errors?: GraphQLError[] }; + try { + parsed = JSON.parse(text); + } catch { + throw new OtperApiError( + `Non-JSON upload response (${res.status}): ${text.slice(0, 200)}`, + res.status, + ); + } + if (parsed.errors?.length) { + const msg = parsed.errors.map((e) => e.message).join('; '); + throw new OtperApiError(`GraphQL upload error: ${msg}`, res.status, parsed.errors, parsed); + } + if (!res.ok || !parsed.data) { + throw new OtperApiError( + `HTTP ${res.status}: ${text.slice(0, 200)}`, + res.status, + undefined, + parsed, + ); + } + return parsed.data.upload; +} diff --git a/src/commands/card/upload.ts b/src/commands/card/upload.ts new file mode 100644 index 0000000..445a65c --- /dev/null +++ b/src/commands/card/upload.ts @@ -0,0 +1,61 @@ +import { Args, Flags } from '@oclif/core'; +import { BaseCommand } from '../../base'; +import { Column } from '../../format'; +import { uploadFile, UploadedFile } from '../../api/files'; + +const COLUMNS: Column[] = [ + { header: 'ID', get: (f) => String(f.id) }, + { header: 'Name', get: (f) => f.name }, + { header: 'Key', get: (f) => f.key }, + { header: 'Size', get: (f) => String(f.size) }, + { header: 'Disk', get: (f) => f.disk }, + { header: 'Created', get: (f) => f.created_at ?? '' }, +]; + +export default class CardUpload extends BaseCommand { + static description = 'Upload one or more files and attach them to a card.'; + + static strict = false; + + static examples = [ + '<%= config.bin %> card:upload 37234 ./screenshot.png', + '<%= config.bin %> card:upload 37234 ./a.png ./b.pdf --filename report.pdf', + ]; + + static args = { + cardId: Args.string({ description: 'Card ID', required: true }), + }; + + static flags = { + filename: Flags.string({ + summary: 'Override the filename sent to the server (single-file uploads only).', + }), + 'mime-type': Flags.string({ + summary: 'Override the MIME type. Defaults to detection from the file extension.', + }), + }; + + async run(): Promise { + const { argv, args } = await this.parse(CardUpload); + const filePaths = argv.slice(1) as string[]; + if (filePaths.length === 0) this.error('Provide at least one file path.'); + if (this.flags.filename && filePaths.length > 1) { + this.error('--filename can only be used with a single file.'); + } + const results: UploadedFile[] = []; + for (const filePath of filePaths) { + const result = await uploadFile(this.api, { + cardId: args.cardId, + filePath, + filename: this.flags.filename, + mimeType: this.flags['mime-type'], + }); + results.push(result); + } + this.output(results, { + columns: COLUMNS, + vertical: results.length === 1, + json: results.length === 1 ? results[0] : results, + }); + } +} diff --git a/src/commands/comment/upload.ts b/src/commands/comment/upload.ts new file mode 100644 index 0000000..bb0a51a --- /dev/null +++ b/src/commands/comment/upload.ts @@ -0,0 +1,67 @@ +import { Args, Flags } from '@oclif/core'; +import { BaseCommand } from '../../base'; +import { Column } from '../../format'; +import { uploadFile, UploadedFile } from '../../api/files'; + +const COLUMNS: Column[] = [ + { header: 'ID', get: (f) => String(f.id) }, + { header: 'Name', get: (f) => f.name }, + { header: 'Key', get: (f) => f.key }, + { header: 'Size', get: (f) => String(f.size) }, + { header: 'Disk', get: (f) => f.disk }, + { header: 'Created', get: (f) => f.created_at ?? '' }, +]; + +export default class CommentUpload extends BaseCommand { + static description = + 'Upload one or more files and attach them to a comment. Requires the comment\'s parent card id.'; + + static strict = false; + + static examples = [ + '<%= config.bin %> comment:upload 12345 --card 37234 ./screenshot.png', + ]; + + static args = { + commentId: Args.string({ description: 'Comment ID', required: true }), + }; + + static flags = { + card: Flags.string({ + summary: 'Card ID the comment belongs to (required).', + required: true, + }), + filename: Flags.string({ + summary: 'Override the filename sent to the server (single-file uploads only).', + }), + 'mime-type': Flags.string({ + summary: 'Override the MIME type. Defaults to detection from the file extension.', + }), + }; + + async run(): Promise { + const { argv, args } = await this.parse(CommentUpload); + const filePaths = argv.slice(1) as string[]; + if (filePaths.length === 0) this.error('Provide at least one file path.'); + if (this.flags.filename && filePaths.length > 1) { + this.error('--filename can only be used with a single file.'); + } + const results: UploadedFile[] = []; + for (const filePath of filePaths) { + const result = await uploadFile(this.api, { + cardId: this.flags.card, + ownerType: 'Comment', + ownerId: args.commentId, + filePath, + filename: this.flags.filename, + mimeType: this.flags['mime-type'], + }); + results.push(result); + } + this.output(results, { + columns: COLUMNS, + vertical: results.length === 1, + json: results.length === 1 ? results[0] : results, + }); + } +} diff --git a/src/index.ts b/src/index.ts index 0e0c8b8..c39f4a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,3 +30,4 @@ export * as comments from './api/comments'; export * as users from './api/users'; export * as teams from './api/teams'; export * as priorities from './api/priorities'; +export * as files from './api/files';