Image To OneDrive

一个在向Markdown文件粘贴图片时自动上传到OneDrive并获取直链的VSCode插件

起因

由于之前的博客源码在WSL中,但由于WSL损坏导致了数据丢失,博客源码跟着丢了,最近在重构博客,重构的时候发现文章图片迁移很麻烦,需要先从浏览器下载,接着将图片复制到文章对应目录内,再在文章中插入,想起了以前写博客的时候,截完图要先粘贴,接着把图片移到对应目录,再修改图片链接的过程,因此决定使用图床。

过程

图床选择

有很多公开图床可以使用,也有很多插件,但是VSCode使用的大多是PicGo和代码仓库组合,但是为了国内外都能快速访问,我决定使用我的OneDrive作为图床,因为之前在ISCTF出题时使用OneDrive作为C2中转地址,且国内外访问都很稳定,所以对OneDrive的稳定性还是比较信赖,我的Microsoft 365有5T空间,目前大部分闲置,刚好可以利用,但是没有现成的插件可以用,很多文章也是PicGo和第三方图床组合,第三方图床调用OneDrive,过程不透明,于是想自己写一个

插件编写

没有写过VSCode的插件,文档也不想看,就想着用AI写一个,最近刚把GitLabCI配置好,只要往仓库推送就可以自动编译,GitLab还自带WebIDE,直接把代码复制过去就可以,先用了Copilot写了一版,调整几次提示词后没有效果,又用Microsoft 365送的企业版Copilot写了一版,编译一直出问题,最后用GitHub Copilot写,自动配环境测试,调到能编译和打包了,本地测试,先是插件可以登录账号和上传文件,但是不能捕获到剪贴板粘贴事件,通过给插件单独的粘贴命令+绑定Ctrl+V快捷键解决了

1
2
3
4
5
6
7
"keybindings": [
{
"command": "imageToOneDrive.pasteImage",
"key": "ctrl+alt+v",
"when": "editorTextFocus && editorLangId == markdown"
}
]

接着发现通过QQ截图后粘贴会报错,找不到图片,通过powershell命令

1
2
3
4
5
6
7
8
Add-Type -AssemblyName System.Windows.Forms;
Add-Type -AssemblyName System.Drawing;
$out='${outPathEscaped}';
$img=[System.Windows.Forms.Clipboard]::GetImage();
if ($img -ne $null) {
$img.Save($out, [System.Drawing.Imaging.ImageFormat]::Png);
Write-Output 'SAVED'
}

临时存储为文件后上传解决了,但是复制图片文件之后粘贴没有效果,于是查看Clipboard文档,发现其中有GetFileDropList方法,本地测试后发现会返回一个文件列表,想做多图片上传,但是发现插件代码只提供一个$out,如果要实现需要改很多,就只判断了第一个文件是不是PNG

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Add-Type -AssemblyName System.Windows.Forms;
Add-Type -AssemblyName System.Drawing;
$out='${outPathEscaped}';
$img=[System.Windows.Forms.Clipboard]::GetImage();
if ($img -ne $null) {
$img.Save($out, [System.Drawing.Imaging.ImageFormat]::Png);
Write-Output 'SAVED'
} else {
$file_ori=[System.Windows.Forms.Clipboard]::GetFileDropList()[0];
$file=$file_ori.tolower();
if ($file.endswith('.png')) {
Copy-Item $file $out;
Write-Output 'SAVED'
}
}

粘贴问题解决后,发现在粘贴失败的时候不会触发原VSCode行为,而且文本也没法粘贴,查看代码发现因为绑定了快捷键,还注册了粘贴事件,导致默认粘贴事件没有触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// 注册事件
if ((vscode.languages as any).registerDocumentPasteEditProvider) {
const provider = {
provideDocumentPasteEdits: async (document: any, ranges: any, data: any) => {
try {
output.appendLine('paste provider: handling paste');
vscode.window.showInformationMessage('Image To OneDrive: handling paste...');
const tmp = await trySaveClipboardImageToTemp();
if (!tmp) { output.appendLine('paste provider: no binary image in clipboard'); vscode.window.showInformationMessage('Image To OneDrive: no binary image in clipboard'); return undefined; }
output.appendLine(`paste provider: found temp file ${tmp}`);
vscode.window.showInformationMessage('Image To OneDrive: uploading clipboard image...');
const link = await client!.uploadLocalFile(tmp);
try { fs.unlinkSync(tmp); } catch (e) { output.appendLine('paste provider: error removing tmp ' + String(e)); }
output.appendLine('paste provider: upload returned link: ' + link);
const insertText = `![](${link})`;
const edit = new vscode.WorkspaceEdit();
edit.insert(document.uri, ranges[0].start, insertText);
vscode.window.showInformationMessage('Image To OneDrive: inserted link');
return { edit };
} catch (e) {
output.appendLine('paste provider: failed ' + String(e));
vscode.window.showErrorMessage('Image To OneDrive paste/upload failed: ' + String(e));
return undefined;
}
}
};
(vscode.languages as any).registerDocumentPasteEditProvider({ language: 'markdown' }, provider, { pasteMimeTypes: ['image/png', 'image/jpeg'] });
}

// 绑定快捷键
context.subscriptions.push(
vscode.commands.registerCommand('imageToOneDrive.pasteImage', async () => {
try {

// Prefer binary clipboard image extraction (wl-paste, pngpaste, xclip, etc.)
const tmp = await trySaveClipboardImageToTemp();
if (tmp) {
try {
const link = await uploadWithProgress(tmp);
await insertLink(link);
vscode.window.showInformationMessage('Pasted image uploaded to OneDrive.');
} finally {
try { fs.unlinkSync(tmp); } catch {}
}
return;
}

// Fallback: try text data URL from clipboard
const text = await vscode.env.clipboard.readText();
if (text && text.startsWith('data:')) {
// data URL
const matches = text.match(/^data:(image\/[^;]+);base64,(.+)$/);
if (!matches) throw new Error('Clipboard data is not a supported data URL.');
const mime = matches[1];
const b64 = matches[2];
const buffer = Buffer.from(b64, 'base64');
const ext = mime.split('/')[1] || 'png';
const tmp2 = path.join(os.tmpdir(), `vscode-image-${Date.now()}.${ext}`);
fs.writeFileSync(tmp2, buffer);
const link = await uploadWithProgress(tmp2);
await insertLink(link);
fs.unlinkSync(tmp2);
vscode.window.showInformationMessage('Pasted image uploaded to OneDrive.');
return;
}
if (tmp) {
try {
const link = await uploadWithProgress(tmp);
await insertLink(link);
vscode.window.showInformationMessage('Pasted image uploaded to OneDrive.');
} finally {
try { fs.unlinkSync(tmp); } catch {}
}
return;
}

vscode.window.showWarningMessage('Clipboard does not contain an image data URL or binary image. Install clipboard helper tools (pngpaste, wl-paste, xclip/xsel) or use "Upload File...".');
} catch (err: any) {
output.appendLine('pasteImage command failed: ' + String(err));
// Fallback to VS Code's native paste action so the editor handles the clipboard
try {
await vscode.commands.executeCommand('editor.action.clipboardPasteAction');
} catch (e) {
vscode.window.showErrorMessage('Paste failed and fallback paste also failed: ' + String(e));
}
}
})
);

由于快捷键绑定劫持了默认事件,而快捷键对应函数中没有错误处理,直接导致粘贴功能失效,于是修改快捷键为Ctrl+Alt+V,并加入触发事件代码

1
2
3
4
5
6
7
8
9
10
11
12
...
vscode.window.showWarningMessage('Clipboard does not contain an image data URL or binary image. Install clipboard helper tools (pngpaste, wl-paste, xclip/xsel) or use "Upload File...".');
output.appendLine('pasteImage command failed: ' + String(err));
// Fallback to VS Code's native paste action so the editor handles the clipboard
try {
await vscode.commands.executeCommand('editor.action.clipboardPasteAction');
} catch (e) {
vscode.window.showErrorMessage('Paste failed and fallback paste also failed: ' + String(e));
}
}
} catch (err: any) {
...

注册的事件出错返回undefinedVSCode捕获到后会自动处理,修改代码后文本粘贴和错误处理均正常,接着发现直链还有问题,默认生成的链接包含tempauth参数,查询文档发现有效期为一小时,这时想到了之前用的直链转换工具,查看原理发现企业版只需要对URL进行处理即可,个人版需要转换,经过测试,个人版即使转换也只有一小时有效期,获取URL过程使用了Microsoft GraphAPI,分别为

1
2
3
4
5
6
7
8
9
10
// 上传
const url = `${GRAPH_BASE}/me/drive/root:${encodeURI(remotePath)}:/content`;
const body = fs.readFileSync(localPath);
const res = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`
},
body
});

返回的JSON中包含id,即文件的ItemId,接着获取分享链接

1
2
3
4
5
6
7
8
const linkRes = await fetch(`${GRAPH_BASE}/me/drive/items/${id}/createLink`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ type: 'view', scope: 'anonymous' })
});

这里AI给的是直接从上传的返回结果获取downloadUrl,但是有效期太短,于是我把获取的代码注释掉,强制执行创建分享链接,接着获取返回内容中link内的webUrl,进行转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function transformShareToDownload(webUrl: string): string | null {
try {
const u = new URL(webUrl);
const p = u.pathname || '';
// pattern containing ":u:/" and "/personal/"
if (p.includes('/:i:') || p.includes('/:i:/') || p.includes('%3Ai%3A')) {
// normalize and split
const clean = decodeURIComponent(p);
const parts = clean.split('/').filter(Boolean);
// find 'personal' index
const idx = parts.indexOf('personal');
if (idx >= 0 && parts.length > idx + 1) {
const user = parts[idx + 1];
const token = parts[parts.length - 1];
if (user && token) {
// construct download.aspx share URL (use _layouts/52 per request)
return `${u.protocol}//${u.host}/personal/${user}/_layouts/52/download.aspx?share=${encodeURIComponent(token)}`;
}
}
}
} catch (e) {
// ignore
}
return null;
}

即可获得链接,到这里直链问题解决了,个人版OneDrive没法解决,直接在应用里关掉了个人版登录,接着测试使用其他组织的账号登录,测试时发现提示需要管理员同意,询问AI得知用户仅能自行授权低权限,高权限需要管理员授权,检查权限列表

1
2
3
4
5
User.Read // 获取登录的用户
Files.ReadWrite.All // 控制用户在OneDrive中的文件
offline_access // 设备码登录
profile // 获取用户账户类型
openid // 授权用户登录应用

其中Files.ReadWrite.All是高权限,可以读取和写入组织内的共享文件,用户无法自行同意,根据插件功能调整为Files.ReadWrite,不在高权限范围内,但是仍然无法授权,查看文档发现用户仅能授权已验证的发布者,未验证无法授权,而验证需要MPN ID,申请MPN ID需要公司,故放弃,在插件配置中加入clientId配置项,用户可以自行在组织内创建应用并填入应用clientId,并自行授权,或者找组织管理员授权,考虑到有需求的用户并不多,于是也不打算上架VSCode扩展商店,还有一些Bug暂时不打算修,可以尝试禁用或卸载插件

结果

插件仓库

Image_To_OneDrive

安装方法

下载VSIX并使用

进入仓库链接后在左侧找到Build,选择其中的Jobs即可看到构建任务
Jobs
点击下载即可,但貌似有有效期,后续会放到Releases

接着在VSCode的扩展面板中选择Install From VSIX...即可安装

自行构建

克隆整个项目,安装node环境,运行npm run prepare:package && npm run package即可获得文件

使用方法

登录

安装后正常的话会自动弹出设备登录页面,设备码也已经复制到剪贴板,粘贴即可,后续正常登录授权即可,如果不能授权可以联系管理员或自行创建应用

登录过期后在命令面板找到Image To OneDrive: Login,运行后和上述流程相同

创建应用

进入Microsoft Entra,登录账号后在左侧选择Entra Id -> 应用注册
应用注册
选择新注册,填写应用名称,选择范围,注册成功后查看应用,选择API权限,按照上方权限列表选择,在概述中找到应用 ID,填入插件设置,重新登录即可

配置

在插件设置中可以设置clientId上传路径上传文件命名

粘贴

直接使用Ctrl+V即可粘贴,有概率失败,在View -> Output可以查看插件输出,对应错误信息或成功路径,使用Image To OneDrive: Paste ImageCtrl+Alt+V命令大概率一次成功

上传

对于无法粘贴的情况可以使用Image To OneDrive: Upload File...可以选择图片或非图片文件上传,上传后得到路径

远程开发

使用VSCodeRemote插件进行远程开发时,插件会自动安装到远程并在远程启用,这可能导致插件无法读取剪贴板内容,需要在Remote插件的Extension Kind加入以下设置

1
2
3
4
5
"remote.extensionKind": {
"undefined_publisher.image-to-onedrive": [
"ui"
]
}