小工具 - 強制解除 Word/Excel 檔的 SharePoint 編輯鎖定
| | 6 | | ![]() |
SharePoint 2016 文件庫提供線上編輯或以桌面版 Word/Excel 開啟兩種選項。線上編輯可直接用瀏覽器編輯較方便且支援多人共同修改,但功能及操作流暢度遠不及桌面版,因此要做粗活兒大家多半還是會開本機的 Word/Excel 作業,反正改完會自動儲存同步回伺服器端也很方便。
用本機 Word/Excel 開啟文件庫檔案時,預設為唯讀模式,啟用編輯模式後 SharePoint 會鎖定該檔案防止別人修改,此時其他人如試圖編輯同一檔案將出現提示:
不過,一直以來常有個困擾 - 前一位編輯者明明已關閉 Word/Excel,其他人開啟該檔案卻一直顯示檔案仍被該使用者鎖定,更好笑的情況是,鎖定者有時還是自己。
推測似乎是 Word/Excel 在結束時未正確解除鎖定導致,並非每次都會發生但蠻常遇到的。爬文發現不少人都遇過類似問題,因此也出現各式解法:參考
- 耐心等 10 分鐘
- 修復 Office
- 清除 OfficeCache
- 重新啟動 WebClient 服務
- 使用 SharePoint Server PowerShell 工具 - Unlock Documents With PowerShell
用 SharePoint PowerShell 工具解鎖是我覺得最直接有效的做法,但需要由鎖定者以其身分執行,發生對象常為不特定的末端使用者,做一個小動作得先安裝 SharePoint PowerShell 模組工程有點浩大。如果想純手工解決,有人分享用 Word/Excel 反覆簽出/簽入釋放鎖定的做法也可一試:參考
回到解鎖工具上,實在不想扯上 SharePoint PowerShell 模組,我想嘗試用 C# 或 PowerShell 配 SharePoint CSOM 程式庫寫成小程式或腳本比較輕巧。研究發現 CSOM 沒有提供對映做法,一個替代做法是設法查出鎖定識別碼,再呼叫 Web Service 解鎖。參考
實際的程式邏輯是呼叫 _vti_bin/_vti_aut/author.dll
傳入文件連結取得 lockId,再呼叫 _vti_bin/cellstorage.svc/CellStorageService
模擬 WCF 呼叫解鎖,查到一篇 JavaScript 版範例 - How To Use JavaScript To Delete Short-Term Locks From Documents Opened From SharePoint?,我把它翻寫成 C# + PowerShell 版,只需傳入文件連結(我用了點小技巧,從 SPS 的 HTTP 302 導向回應拿到站台 URL),只靠 WebClient、HttpWebRequest 完成工作,不需要 SharePoint CSOM 或其他程式庫就搞定。
程式如下,我仍採用腳本內嵌 C# 程式轉成組件供 PowerShell 呼叫的混搭寫法,程式較好寫又避免部署執行檔的疑慮:
Param (
[Parameter(Mandatory = $true)][string]$docUrl
)
$ErrorActionPreference = "STOP"
Add-Type -AssemblyName "System.Web"
if (-not ('UnlockSPDocHelper' -as [type])) {
Add-Type -TypeDefinition @"
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using System.Web;
public class UnlockSPDocHelper
{
public static string PeekRedirectLocation(string url)
{
var req = (HttpWebRequest)WebRequest.Create(url);
req.AllowAutoRedirect = false;
req.Method = "GET";
req.UseDefaultCredentials = true;
using (var resp = req.GetResponse())
{
var loc = resp.Headers[HttpResponseHeader.Location];
if (string.IsNullOrEmpty(loc)) throw new ApplicationException("Not a redirection response");
return loc;
}
}
//REF: https://gist.github.com/tiagoduarte/066653f4d0560981d43cc34c8a0c5f56
public static string CheckLock(string docUrl)
{
try
{
var redirectUrl = PeekRedirectLocation(docUrl);
var siteUrl = redirectUrl.Substring(0, redirectUrl.IndexOf("_"));
var unlockUrl = siteUrl + "_vti_bin/_vti_aut/author.dll";
docUrl = HttpUtility.UrlDecode(docUrl).Split('?').First();
var postBody = "method=getDocsMetaInfo%3a14%2e0%2e0%2e6009&url%5flist=%5b" + System.Web.HttpUtility.UrlEncode(docUrl) + "%5d&listHiddenDocs=false&listLinkInfo=false";
var wc = new WebClient();
wc.Encoding = Encoding.UTF8;
wc.UseDefaultCredentials = true;
wc.Headers.Add(HttpRequestHeader.ContentType, "application/x-www-form-urlencoded");
wc.Headers.Add("X-Vermeer-Content-Type", "application/x-www-form-urlencoded");
wc.Headers.Add("MIME-Version", "1.0");
wc.Headers.Add("User-Agent", "MSFrontPage/14.0");
wc.Headers.Add("Accept", "auth/sicily");
var res = wc.UploadString(unlockUrl, postBody);
var props = new Dictionary<string, string>();
string propName = string.Empty;
res.Split('\n').Where(o => o.StartsWith("<li>"))
.Select(o => o.Substring(4))
.ToList().ForEach(v =>
{
if (Regex.IsMatch(v, "^[A-Z][A-Z][|]"))
props.Add(propName, v.Substring(3));
else propName = v;
});
Func<string, string> getProp = (n) =>
props.ContainsKey(n) ? props[n] : string.Empty;
var lockId = getProp("vti_sourcecontrollockid");
var lockedBy = getProp("vti_sourcecontrolcheckedoutby");
var lockExpires = getProp("vti_sourcecontrollockexpires");
if (string.IsNullOrEmpty(lockId)) return string.Empty;
return string.Format(lockId + "\t" + lockedBy.Split('|').Last().Replace("\", "\\") + "\t" +
DateTime.Parse(lockExpires).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"));
}
catch (Exception ex)
{
return "ERROR-" + ex.Message;
}
}
public static string Unlock(string docUrl, string lockId)
{
try
{
var redirectUrl = PeekRedirectLocation(docUrl);
var siteUrl = redirectUrl.Substring(0, redirectUrl.IndexOf("_"));
var unlockUrl = siteUrl + "_vti_bin/cellstorage.svc/CellStorageService";
docUrl = HttpUtility.UrlDecode(docUrl).Split('?').First();
var releseLockReq = @"
--urn:uuid:8cfcbb22-dd52-4889-b29d-9ff2dcf909b2
Content-ID: <f13ad06d-8530-4af1-8cf3-d6d75c1635d4@tempuri.org>
Content-Transfer-Encoding: 8bit
Content-Type: application/xop+xml;charset=utf-8;type=""text/xml; charset=utf-8""
<s:Envelope xmlns:s=""http://schemas.xmlsoap.org/soap/envelope/"">
<s:Body>
<RequestVersion Version=""2"" MinorVersion=""0"" xmlns=""http://schemas.microsoft.com/sharepoint/soap/""/>
<RequestCollection CorrelationId=""{{35E42C96-FE02-41FE-B4D8-F7DEC43AF784}}"" xmlns=""http://schemas.microsoft.com/sharepoint/soap/"">
<Request Url=""{0}"" RequestToken=""1"">
<SubRequest Type=""ExclusiveLock"" SubRequestToken=""1"">
<SubRequestData ExclusiveLockRequestType=""ReleaseLock"" ExclusiveLockID=""{1}""/>
</SubRequest>
</Request>
</RequestCollection>
</s:Body>
</s:Envelope>
--urn:uuid:8cfcbb22-dd52-4889-b29d-9ff2dcf909b2--";
var postBody = string.Format(releseLockReq, docUrl, lockId);
var wc = new WebClient();
wc.Encoding = Encoding.UTF8;
wc.UseDefaultCredentials = true;
wc.Headers.Add(HttpRequestHeader.ContentType,
@"multipart/related; type=""application/xop+xml""; boundary=""urn:uuid:8cfcbb22-dd52-4889-b29d-9ff2dcf909b2""; start=""<f13ad06d-8530-4af1-8cf3-d6d75c1635d4@tempuri.org>""; start-Info=""text/xml; charset=utf-8""");
wc.Headers.Add("MIME-Version", "1.0");
wc.Headers.Add("User-Agent", "Microsoft Office Upload Center 2010 (14.0.6124) Windows NT 6.1");
wc.Headers.Add("SoapAction", "http://schemas.microsoft.com/sharepoint/soap/ICellStorages/ExecuteCellStorageRequest");
var res = wc.UploadString(unlockUrl, postBody);
if (res.Contains(@"ErrorCode=""Success""")) return "解鎖成功";
return "ERROR-" + res;
}
catch (Exception ex)
{
return "ERROR-" + ex.Message;
}
}
}
"@ -Language CSharp -ReferencedAssemblies ("System.Web")
}
if (!$docUrl.Contains("?")) {
$docUrl += "?web=1"
}
[string]$lockInfo = [UnlockSPDocHelper]::CheckLock($docUrl)
if ([string]::IsNullOrEmpty($lockInfo)) {
Write-Host "檔案未被鎖定" -ForegroundColor Yellow
}
elseif ($lockInfo.Contains("ERROR")) {
Write-Host $lockInfo -ForegroundColor Red
}
else {
$p = $lockInfo.Split("`t")
Write-Host "檔案被 [$($p[1])] 鎖定中(鎖定期間:$($p[2])),若您為鎖定者可強制解鎖,現在要解鎖嗎?(Y/N) " -NoNewline -ForegroundColor Cyan
$confirm = Read-Host
if ($confirm.ToUpper() -eq "Y") {
Write-Host ([UnlockSPDocHelper]::Unlock($docUrl, $p[0])) -ForegroundColor Yellow
}
}
以文件連結作為參數執行 PowerShell Script,程式會先偵測檔案是否被鎖定,如在鎖定中會在詢問後強制解鎖,成功!
Example of C# + PowerShell code to release lock on SharePoint documents.
Comments
# by 資深打字員
這篇太實用了…有太多類似case 感謝分享
# by Al
太強了,真的常遇到被自己鎖住的問題!
# by Leon
不要用 SharePoint 就好,這句話看似廢文其實背後有很深的哲理。
# by My
ERROR-遠端伺服器傳回一個錯誤: (401) 未經授權。 怎樣使用其他帳號登入?
# by Peter
太強了 一試馬上成功 一定要回覆感謝大大的分享!!
# by Ju
請問若文件路徑非http://位置該如何解鎖呢 ,謝謝