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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <info@ssntpl.com>",
"license": "MIT",
Expand Down
173 changes: 173 additions & 0 deletions src/api/files.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, string> = {
'.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<UploadedFile> {
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;
}
61 changes: 61 additions & 0 deletions src/commands/card/upload.ts
Original file line number Diff line number Diff line change
@@ -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<UploadedFile>[] = [
{ 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<typeof CardUpload> {
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<void> {
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,
});
}
}
67 changes: 67 additions & 0 deletions src/commands/comment/upload.ts
Original file line number Diff line number Diff line change
@@ -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<UploadedFile>[] = [
{ 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<typeof CommentUpload> {
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<void> {
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,
});
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading