TT-TMC
21 December 2022 09:14
1
By integrating our web system with Asana,
we have made we can create tasks and attach files to tasks from the web system side .
However, since early December, files attached to Asana have garbled filenames.
(We use Japanese for files)
We use Java source distributed on GitHub for file attachment processing,
“createOnTask” in this URL.
/**
* Upload a file and attach it to a task
*
* @param task Globally unique identifier for the task.
* @param fileContent Content of the file to be uploaded
* @param fileName Name of the file to be uploaded
* @param fileType MIME type of the file to be uploaded
* @return Request object
*/
public ItemRequest<Attachment> createOnTask(String task, InputStream fileContent, String fileName, String fileType) {
MultipartContent.Part part = new MultipartContent.Part()
.setContent(new InputStreamContent(fileType, fileContent))
.setHeaders(new HttpHeaders().set(
"Content-Disposition",
String.format("form-data; name=\"file\"; filename=\"%s\"", fileName) // TODO: escape fileName?
));
MultipartContent content = new MultipartContent()
.setMediaType(new HttpMediaType("multipart/form-data").setParameter("boundary", UUID.randomUUID().toString()))
.addPart(part);
I have confirmed that I am passing the correct string to fileName,
When attached to Asana, the Japanese is garbled.
Our server processing has not changed before and after the phenomenon.
If you are experiencing the same situation,
Does anyone have a solution?
Hi @TT-TMC , I just tried the same in our application and I confirm that filename is garbage after upload.
But, I have no idea if it was ok before, I don’t remember if we tested with complex utf-8 filenames.
But, that’s something I need to look on our side. Maybe we should add a utf-8 encoding headers somewhere I suppose.
TT-TMC
23 December 2022 00:36
3
Hi @Frederic_Malenfant ,When specifying a file name with Content-Disposition,
It was improved by adding UTF-8 encoding header.
We really appreciate your infomation.
public ItemRequest<Attachment> createOnTask(String task, InputStream fileContent, String fileName, String fileType) {
String encFileName = fileName;
try {
encFileName = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
} catch (UnsupportedEncodingException e1) {
e1.printStackTrace();
}
MultipartContent.Part part = new MultipartContent.Part()
.setContent(new InputStreamContent(fileType, fileContent))
.setHeaders(new HttpHeaders().set(
"Content-Disposition",
String.format("form-data; name=\"file\"; filename*=UTF-8''%s", encFileName)
// String.format("form-data; name=\"file\"; filename=\"%s\"", fileName) // TODO: escape fileName?
));
1 Like
Same behaviour on me using the python-asana library (version=3.0.0).
Giving the param file_name some greek characters, the filename of the attachment file uploaded to Asana has wrong encoding.
Request:
client.attachments.create_attachment_for_task(
task_id=data['task_id'],
file_content=attachment,
file_name='τεστ συνημμένο',
file_content_type=attachment.content_type,
opt_pretty=True
)
Asana attachment filename stored:
ÏεÏÏ ÏÏ
νημμÎνο
Note that posting the same request using Postman, the attachment filename is stored with the correct encoding in the Asana task.
Hi! Any news on that? Have same problem with cyrillic filenames:
FILENAME="файл.pdf"
curl -X POST \
'https://app.asana.com/api/1.0/attachments' \
-H 'Authorization: Bearer 2/****/1*****' \
-H 'Content-Type: multipart/form-data' \
--form-string "name=YOUR_DESIRED_FILENAME.pdf" \
-F "file=@\"$FILENAME\";type=application/pdf" \
--form "parent=1208902923833135"
Results in:
Hi @Dzmitry_D ,
Please avoid sharing your Personal Access Token (PAT) to the public (i.e., Bearer 2/12089028006...
). We’ve revoked it since this is a security risk.
1 Like
@Dzmitry_D , I’ve let our engineering team know about this issue. I’ll let you all know if there’s a recommendation.
Also, @ Ilias_Vassilopoulos is right about this working with Postman. There might be an extra header that Postman is sending in the post requests that help preserve the file name.
I believe one customer was able to work around this issue by providing a content-disposition header in their cURL request.
Here is working code to send properly encoded filenames from nodejs
const https = require('follow-redirects').https;
const fs = require('fs');
const path = require('path');
const mime = require('mime-types');
// IMPORTANT: Replace YOUR_ACCESS_TOKEN with your actual Asana access token
// Never share your actual token - keep it private and secure
const ASANA_TOKEN = 'YOUR_ACCESS_TOKEN';
const uploadFileToAsana = (filePath, parentId) => {
const filename = path.basename(filePath);
const mimeType = mime.lookup(filename) || 'application/octet-stream';
const options = {
'method': 'POST',
'hostname': 'app.asana.com',
'path': '/api/1.0/attachments',
'headers': {
'Authorization': `Bearer ${ASANA_TOKEN}`,
'Accept': 'application/json'
},
'maxRedirects': 20
};
const req = https.request(options, (res) => {
const chunks = [];
res.on("data", chunk => chunks.push(chunk));
res.on("end", () => console.log(Buffer.concat(chunks).toString()));
res.on("error", error => console.error(error));
});
const boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW';
req.setHeader('content-type', `multipart/form-data; boundary=${boundary}`);
const encodeFilename = (name) => {
return `UTF-8''${encodeURIComponent(name).replace(/['()]/g, escape)}`;
};
const postData = Buffer.concat([
Buffer.from(`--${boundary}\r\n`),
Buffer.from(`Content-Disposition: form-data; name="file"; filename="${filename}"; filename*=${encodeFilename(filename)}\r\n`),
Buffer.from(`Content-Type: ${mimeType}\r\n\r\n`),
fs.readFileSync(filePath),
Buffer.from(`\r\n--${boundary}\r\n`),
Buffer.from(`Content-Disposition: form-data; name="parent"\r\n\r\n`),
Buffer.from(`${parentId}\r\n`),
Buffer.from(`--${boundary}--`)
]);
req.write(postData);
req.end();
};
// Example usage
uploadFileToAsana('./файл.pdf', 'YOUR_PARENT_ID');
2 Likes
nodejs, axios with properly encoded filename:
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const mime = require('mime-types');
require('dotenv').config();
const uploadFileToAsana = async (filePath, parentId) => {
const filename = path.basename(filePath);
const mimeType = mime.lookup(filename) || 'application/octet-stream';
// Generate boundary
const boundary = '----WebKitFormBoundary' + Math.random().toString(36).slice(2);
// Create form data manually
const form = [];
// Add file part
form.push(Buffer.from(`--${boundary}\r\n`));
form.push(Buffer.from(`Content-Disposition: form-data; name="file"; filename*=utf-8''${encodeURIComponent(filename)}\r\n`));
form.push(Buffer.from(`Content-Type: ${mimeType}\r\n\r\n`));
form.push(fs.readFileSync(filePath));
form.push(Buffer.from('\r\n'));
// Add parent part
form.push(Buffer.from(`--${boundary}\r\n`));
form.push(Buffer.from('Content-Disposition: form-data; name="parent"\r\n\r\n'));
form.push(Buffer.from(`${parentId}\r\n`));
form.push(Buffer.from(`--${boundary}--\r\n`));
// Calculate content length
const contentLength = form.reduce((acc, curr) => acc + curr.length, 0);
try {
const response = await axios({
method: 'post',
url: 'https://app.asana.com/api/1.0/attachments',
headers: {
'Authorization': `Bearer <YOUR_ACCESS_TOKEN>,
'Accept': 'application/json',
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': contentLength
},
data: Buffer.concat(form),
maxBodyLength: Infinity,
maxContentLength: Infinity,
maxRedirects: 20
});
return response.data;
} catch (error) {
console.error('Upload failed:', error.response?.data || error.message);
throw error;
}
};
// Example usage
uploadFileToAsana('./файл.pdf', '1208902923833135')
.then(result => console.log('Upload successful:', result))
.catch(error => console.error('Upload failed:', error));
// module.exports = uploadFileToAsana;
3 Likes
@Dzmitry_D This is awesome. Thank you for sharing your knowledge with the community
I just wanted to follow up on this thread. Here’s how you would make this request using cURL
export ASANA_PAT="<YOUR_ASANA_PERSONAL_ACCESS_TOKEN>"
export PARENT_ID="<TASK_GID>"
export ENCODED_NAME="%D1%84%D0%B0%D0%B8%CC%86%D0%BB.pdf"
curl --location 'https://app.asana.com/api/1.0/attachments' \
--header 'Content-Type: multipart/form-data' \
--header 'Accept: application/json' \
--header "Authorization: Bearer $ASANA_PAT" \
--form "parent=$PARENT_ID" \
-F "file=@/Users/<EXAMPLE_USER>/Downloads/файл.pdf;headers=\"Content-Disposition: form-data; name="file"; filename="$ENCODED_NAME.pdf"; filename*=UTF-8''$ENCODED_NAME.pdf\""
The value for ENCODED_NAME
is from URL encoding the file name файл.pdf
. You can get this encoding by just Googling URL encoder online and pasting in файл.pdf
then hitting encode. It should output something like: %D1%84%D0%B0%D0%B8%CC%86%D0%BB.pdf
2 Likes
Here is updated code that use content-disposition
library, since we have noticed using just encodeURIComponent is not covering some edge cases:
# uploadFileToAsana.ts
import axios, { AxiosResponse, AxiosError } from 'axios';
import fs from 'fs';
import * as Asana from 'asana';
import path from 'path';
import mime from 'mime-types';
import { ActionQueueItem } from '../../../types/index.js';
import contentDisposition from 'content-disposition';
/**
* Uploads a file to Asana task as an attachment using multipart/form-data.
* Handles file metadata and content type detection.
*/
export async function uploadFileToAsana(action: ActionQueueItem): Promise<string> {
if (!action.attachment) throw new Error('No attachment info in action item');
const extension: string = action.attachment.name.split('.').pop() as string;
const filePath: string = path.join(
'attachments',
action.otherTrackerIssueId,
`${action.attachment.otherTrackerAttachmentId}.${extension}`
);
const mimeResult: string | false = mime.lookup(path.basename(filePath));
const mimeType: string = mimeResult ? mimeResult : 'application/octet-stream';
const boundary: string = `----WebKitFormBoundary${Math.random().toString(36).slice(2)}`;
const form: Buffer[] = [];
const cdShort: string = contentDisposition(action.attachment.name, { type: 'form-data' });
const cdFull: string = `Content-Disposition: ${cdShort.replace(/^form-data;/u, 'form-data; name="file";')}\r\n`;
form.push(Buffer.from(`--${boundary}\r\n`));
form.push(Buffer.from(cdFull));
form.push(Buffer.from(`Content-Type: ${mimeType}\r\n\r\n`));
form.push(fs.readFileSync(filePath));
form.push(Buffer.from('\r\n'));
form.push(Buffer.from(`--${boundary}\r\n`));
form.push(Buffer.from('Content-Disposition: form-data; name="parent"\r\n\r\n'));
form.push(Buffer.from(`${action.attachment.Task.asanaGid}\r\n`));
form.push(Buffer.from(`--${boundary}--\r\n`));
const contentLength: number = form.reduce((acc, curr) => acc + curr.length, 0);
try {
const response: AxiosResponse<Asana.Response<Asana.Attachment>> = await axios({
method: 'post',
url: 'https://app.asana.com/api/1.0/attachments',
headers: {
'Authorization': `Bearer ${process.env.ASANA_SERVICE_USER_TOKEN}`,
'Accept': 'application/json',
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': contentLength
},
data: Buffer.concat(form),
maxBodyLength: Infinity,
maxContentLength: Infinity,
maxRedirects: 20
});
if (!response.data?.data?.gid) throw new Error('No uploaded attachment gid!');
return response.data.data.gid;
} catch (error) {
const axiosError: AxiosError<{
errors: {
message: string;
}[];
}, unknown> = error as AxiosError<{ errors: { message: string }[] }>;
if (axiosError.response?.data) {
console.error('Upload failed:', axiosError.response.data);
} else {
console.error('Upload failed:', axiosError.message);
}
throw error;
}
}
#types.d.ts
namespace Asana {
export interface Error {
response?: {
status?: number;
text?: string;
data?: {
errors?: { message: string }[];
};
};
message?: string;
}
export interface Response<T> {
data: T;
}
export interface Attachment {
gid: string;
}
}
interface ActionQueueItem {
action: 'attachment';
attachment?: {
otherTrackerAttachmentId: string;
otherTrackerIssueId: string;
name: string;
Task: {
asanaGid: string;
};
};
otherTrackerIssueId: string;
asanaTaskGid: string;
}