Image To OneDrive一个在向Markdown文件粘贴图片时自动上传到OneDrive并获取直链的VSCode插件
起因 由于之前的博客源码在WSL中,但由于WSL损坏导致了数据丢失,博客源码跟着丢了,最近在重构博客,重构的时候发现文章图片迁移很麻烦,需要先从浏览器下载,接着将图片复制到文章对应目录内,再在文章中插入,想起了以前写博客的时候,截完图要先粘贴,接着把图片移到对应目录,再修改图片链接的过程,因此决定使用图床。
过程 图床选择 有很多公开图床可以使用,也有很多插件,但是VSCode使用的大多是PicGo和代码仓库组合,但是为了国内外都能快速访问,我决定使用我的OneDrive作为图床,因为之前在ISCTF出题时使用OneDrive作为C2中转地址,且国内外访问都很稳定,所以对OneDrive的稳定性还是比较信赖,我的Microsoft 365有5T空间,目前大部分闲置,刚好可以利用,但是没有现成的插件可以用,很多文章也是PicGo和第三方图床组合,第三方图床调用OneDrive,过程不透明,于是想自己写一个
插件编写 没有写过VSCode的插件,文档也不想看,就想着用AI写一个,最近刚把GitLab 的CI配置好,只要往仓库推送就可以自动编译,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 = `` ; 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 { 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 ; } const text = await vscode.env .clipboard .readText (); if (text && text.startsWith ('data:' )) { 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)); 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)); 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 ) { ...
注册的事件出错返回undefined,VSCode捕获到后会自动处理,修改代码后文本粘贴和错误处理均正常,接着发现直链还有问题,默认生成的链接包含tempauth参数,查询文档发现有效期为一小时,这时想到了之前用的直链转换工具,查看原理发现企业版只需要对URL进行处理即可,个人版需要转换,经过测试,个人版即使转换也只有一小时有效期,获取URL过程使用了Microsoft Graph的API,分别为
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 || '' ; if (p.includes ('/:i:' ) || p.includes ('/:i:/' ) || p.includes ('%3Ai%3A' )) { const clean = decodeURIComponent (p); const parts = clean.split ('/' ).filter (Boolean ); 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) { return `${u.protocol} //${u.host} /personal/${user} /_layouts/52/download.aspx?share=${encodeURIComponent (token)} ` ; } } } } catch (e) { } 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即可看到构建任务 点击下载即可,但貌似有有效期,后续会放到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 Image或Ctrl+Alt+V命令大概率一次成功
上传 对于无法粘贴的情况可以使用Image To OneDrive: Upload File...可以选择图片或非图片文件上传,上传后得到路径
远程开发 使用VSCode的Remote插件进行远程开发时,插件会自动安装到远程并在远程启用,这可能导致插件无法读取剪贴板内容,需要在Remote插件的Extension Kind加入以下设置
1 2 3 4 5 "remote.extensionKind" : { "undefined_publisher.image-to-onedrive" : [ "ui" ] }