PageRenderTime 29ms CodeModel.GetById 20ms RepoModel.GetById 1ms app.codeStats 0ms

/WikiFunctions/API/ApiEdit.cs

https://github.com/svick/AWB
C# | 1306 lines | 896 code | 204 blank | 206 comment | 102 complexity | 9c9de462d75aee4eaeb9d8ca7ce9c9fb MD5 | raw file
Possible License(s): GPL-2.0
  1. /*
  2. Copyright (C) 2008 Max Semenik
  3. This program is free software; you can redistribute it and/or modify
  4. it under the terms of the GNU General Public License as published by
  5. the Free Software Foundation; either version 2 of the License, or
  6. (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU General Public License for more details.
  11. You should have received a copy of the GNU General Public License
  12. along with this program; if not, write to the Free Software
  13. Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
  14. */
  15. using System;
  16. using System.Collections.Generic;
  17. using System.Text;
  18. using System.Net;
  19. using System.Reflection;
  20. using System.Web;
  21. using System.IO;
  22. using System.Xml;
  23. using System.Threading;
  24. using System.Text.RegularExpressions;
  25. namespace WikiFunctions.API
  26. {
  27. //TODO: refactor XML parsing
  28. //TODO: generalise edit token retrieval
  29. /// <summary>
  30. /// This class edits MediaWiki sites using api.php
  31. /// </summary>
  32. /// <remarks>
  33. /// MediaWiki API manual: http://www.mediawiki.org/wiki/API
  34. /// Site prerequisites: MediaWiki 1.13+ with the following settings:
  35. /// * $wgEnableAPI = true; (enabled by default in DefaultSettings.php)
  36. /// * $wgEnableWriteAPI = true;
  37. /// * AssertEdit extension installed (http://www.mediawiki.org/wiki/Extension:Assert_Edit)
  38. /// </remarks>
  39. public class ApiEdit : IApiEdit
  40. {
  41. /// <summary>
  42. ///
  43. /// </summary>
  44. private ApiEdit()
  45. {
  46. Cookies = new CookieContainer();
  47. User = new UserInfo();
  48. NewMessageThrows = true;
  49. }
  50. /// <summary>
  51. /// Creates a new instance of the ApiEdit class
  52. /// </summary>
  53. /// <param name="url">Path to scripts on server</param>
  54. public ApiEdit(string url)
  55. : this(url, false)
  56. {
  57. }
  58. /// <summary>
  59. /// Creates a new instance of the ApiEdit class
  60. /// </summary>
  61. /// <param name="url">Path to scripts on server</param>
  62. /// <param name="usePHP5">Whether a .php5 extension is to be used</param>
  63. public ApiEdit(string url, bool usePHP5)
  64. : this()
  65. {
  66. if (string.IsNullOrEmpty(url)) throw new ArgumentException("Invalid URL specified", "url");
  67. if (!url.StartsWith("http://")) throw new NotSupportedException("Only editing via HTTP is currently supported");
  68. URL = url;
  69. PHP5 = usePHP5;
  70. ApiURL = URL + "api.php" + (PHP5 ? "5" : "");
  71. Maxlag = 5;
  72. IWebProxy proxy;
  73. if (ProxyCache.TryGetValue(url, out proxy))
  74. {
  75. ProxySettings = proxy;
  76. }
  77. else
  78. {
  79. ProxySettings = WebRequest.GetSystemWebProxy();
  80. if (ProxySettings.IsBypassed(new Uri(url)))
  81. {
  82. ProxySettings = null;
  83. }
  84. ProxyCache.Add(url, ProxySettings);
  85. }
  86. }
  87. public IApiEdit Clone()
  88. {
  89. return new ApiEdit
  90. {
  91. URL = URL,
  92. ApiURL = ApiURL,
  93. PHP5 = PHP5,
  94. Maxlag = Maxlag,
  95. Cookies = Cookies,
  96. ProxySettings = ProxySettings,
  97. User = User
  98. };
  99. }
  100. #region Properties
  101. /// <summary>
  102. /// Path to scripts on server
  103. /// </summary>
  104. public string URL { get; private set; }
  105. /// <summary>
  106. ///
  107. /// </summary>
  108. public string ApiURL { get; private set; }
  109. /// <summary>
  110. ///
  111. /// </summary>
  112. private string Server
  113. { get { return "http://" + new Uri(URL).Host; } }
  114. /// <summary>
  115. ///
  116. /// </summary>
  117. public bool PHP5 { get; private set; }
  118. /// <summary>
  119. /// Maxlag parameter of every request (http://www.mediawiki.org/wiki/Manual:Maxlag_parameter)
  120. /// </summary>
  121. public int Maxlag { get; set; }
  122. /// <summary>
  123. ///
  124. /// </summary>
  125. public bool NewMessageThrows
  126. { get; set; }
  127. /// <summary>
  128. /// Action for which we have edit token
  129. /// </summary>
  130. public string Action { get; private set; }
  131. /// <summary>
  132. /// Name of the page currently being edited
  133. /// </summary>
  134. public PageInfo Page
  135. { get; private set; }
  136. /// <summary>
  137. ///
  138. /// </summary>
  139. public string HtmlHeaders
  140. { get; private set; }
  141. /// <summary>
  142. /// Cookies stored between requests
  143. /// </summary>
  144. public CookieContainer Cookies { get; private set; }
  145. #endregion
  146. /// <summary>
  147. /// Resets all internal variables, discarding edit tokens and so on,
  148. /// but does not logs off
  149. /// </summary>
  150. public void Reset()
  151. {
  152. Action = null;
  153. Page = new PageInfo();
  154. Aborting = false;
  155. Request = null;
  156. }
  157. /// <summary>
  158. ///
  159. /// </summary>
  160. public void Abort()
  161. {
  162. Aborting = true;
  163. Request.Abort();
  164. Thread.Sleep(1);
  165. Aborting = false;
  166. }
  167. /// <summary>
  168. /// This is a hack required for some multilingual Wikimedia projects,
  169. /// where CentralAuth returns cookies with a redundant domain restriction.
  170. /// </summary>
  171. private void AdjustCookies()
  172. {
  173. string host = new Uri(URL).Host;
  174. var newCookies = new CookieContainer();
  175. var urls = new[] { new Uri(URL), new Uri("http://fnord." + host) };
  176. foreach (var u in urls)
  177. {
  178. foreach (Cookie c in Cookies.GetCookies(u))
  179. {
  180. c.Domain = host;
  181. newCookies.Add(c);
  182. }
  183. }
  184. Cookies = newCookies;
  185. }
  186. #region URL stuff
  187. /// <summary>
  188. ///
  189. /// </summary>
  190. /// <param name="request"></param>
  191. /// <returns></returns>
  192. protected static string BuildQuery(string[,] request)
  193. {
  194. StringBuilder sb = new StringBuilder();
  195. for (int i = 0; i <= request.GetUpperBound(0); i++)
  196. {
  197. string s = request[i, 0];
  198. if (string.IsNullOrEmpty(s)) continue;
  199. sb.Append('&');
  200. sb.Append(s);
  201. s = request[i, 1];
  202. if (s != null) // empty string is a valid parameter value!
  203. {
  204. sb.Append('=');
  205. sb.Append(HttpUtility.UrlEncode(s));
  206. }
  207. }
  208. return sb.ToString();
  209. }
  210. /// <summary>
  211. ///
  212. /// </summary>
  213. /// <param name="titles"></param>
  214. /// <returns></returns>
  215. protected static string Titles(params string[] titles)
  216. {
  217. for (int i = 0; i < titles.Length; i++) titles[i] = Tools.WikiEncode(titles[i]);
  218. if (titles.Length > 0) return "&titles=" + string.Join("|", titles);
  219. return "";
  220. }
  221. /// <summary>
  222. ///
  223. /// </summary>
  224. /// <param name="paramName"></param>
  225. /// <param name="titles"></param>
  226. /// <returns></returns>
  227. protected static string NamedTitles(string paramName, params string[] titles)
  228. {
  229. for (int i = 0; i < titles.Length; i++) titles[i] = Tools.WikiEncode(titles[i]);
  230. if (titles.Length > 0) return "&" + paramName + "=" + string.Join("|", titles);
  231. return "";
  232. }
  233. protected string AppendOptions(string url, ActionOptions options)
  234. {
  235. if ((options & ActionOptions.CheckMaxlag) > 0 && Maxlag > 0)
  236. url += "&maxlag=" + Maxlag;
  237. if ((options & ActionOptions.RequireLogin) > 0)
  238. url += "&assert=user";
  239. if ((options & ActionOptions.CheckNewMessages) > 0)
  240. url += "&meta=userinfo&uiprop=hasmsg";
  241. return url;
  242. }
  243. /// <summary>
  244. ///
  245. /// </summary>
  246. /// <param name="request"></param>
  247. /// <param name="options"></param>
  248. /// <returns></returns>
  249. protected string BuildUrl(string[,] request, ActionOptions options)
  250. {
  251. string url = ApiURL + "?format=xml" + BuildQuery(request);
  252. return AppendOptions(url, options);
  253. }
  254. /// <summary>
  255. ///
  256. /// </summary>
  257. /// <param name="request"></param>
  258. /// <returns></returns>
  259. protected string BuildUrl(string[,] request)
  260. {
  261. return BuildUrl(request, ActionOptions.None);
  262. }
  263. #endregion
  264. #region Network access
  265. private static readonly Dictionary<string, IWebProxy> ProxyCache = new Dictionary<string, IWebProxy>();
  266. private IWebProxy ProxySettings;
  267. private static readonly string UserAgent = string.Format("WikiFunctions ApiEdit/{0} ({1}; .NET CLR {2})",
  268. Assembly.GetExecutingAssembly().GetName().Version,
  269. Environment.OSVersion.VersionString,
  270. Environment.Version);
  271. /// <summary>
  272. ///
  273. /// </summary>
  274. /// <param name="url"></param>
  275. /// <returns></returns>
  276. protected HttpWebRequest CreateRequest(string url)
  277. {
  278. if (Globals.UnitTestMode) throw new Exception("You shouldn't access Wikipedia from unit tests");
  279. ServicePointManager.Expect100Continue = false;
  280. HttpWebRequest res = (HttpWebRequest)WebRequest.Create(url);
  281. res.ServicePoint.Expect100Continue = false;
  282. res.Expect = "";
  283. if (ProxySettings != null)
  284. {
  285. res.Proxy = ProxySettings;
  286. res.UseDefaultCredentials = true;
  287. }
  288. res.UserAgent = UserAgent;
  289. res.AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip;
  290. // SECURITY: don't send cookies to third-party sites
  291. if (url.StartsWith(URL)) res.CookieContainer = Cookies;
  292. return res;
  293. }
  294. private bool Aborting;
  295. private HttpWebRequest Request;
  296. /// <summary>
  297. ///
  298. /// </summary>
  299. /// <param name="req"></param>
  300. /// <returns></returns>
  301. protected string GetResponseString(HttpWebRequest req)
  302. {
  303. Request = req;
  304. try
  305. {
  306. using (WebResponse resp = req.GetResponse())
  307. {
  308. using (StreamReader sr = new StreamReader(resp.GetResponseStream()))
  309. {
  310. return sr.ReadToEnd();
  311. }
  312. }
  313. }
  314. catch (WebException ex)
  315. {
  316. var resp = (HttpWebResponse)ex.Response;
  317. if (resp == null) throw;
  318. switch (resp.StatusCode)
  319. {
  320. case HttpStatusCode.NotFound /*404*/:
  321. return ""; // emulate the behaviour of Tools.HttpGet()
  322. }
  323. // just reclassifying
  324. if (ex.Status == WebExceptionStatus.RequestCanceled)
  325. {
  326. throw new AbortedException(this);
  327. }
  328. else
  329. {
  330. throw;
  331. }
  332. }
  333. finally
  334. {
  335. Request = null;
  336. }
  337. }
  338. private string[,] lastPostParameters;
  339. private string lastGetUrl;
  340. /// <summary>
  341. ///
  342. /// </summary>
  343. /// <param name="get"></param>
  344. /// <param name="post"></param>
  345. /// <param name="options"></param>
  346. /// <returns></returns>
  347. protected string HttpPost(string[,] get, string[,] post, ActionOptions options)
  348. {
  349. string url = BuildUrl(get, options);
  350. lastGetUrl = url;
  351. lastPostParameters = post;
  352. string query = BuildQuery(post);
  353. byte[] postData = Encoding.UTF8.GetBytes(query);
  354. HttpWebRequest req = CreateRequest(url);
  355. req.Method = "POST";
  356. req.ContentType = "application/x-www-form-urlencoded";
  357. req.ContentLength = postData.Length;
  358. using (Stream rs = req.GetRequestStream())
  359. {
  360. rs.Write(postData, 0, postData.Length);
  361. }
  362. return GetResponseString(req);
  363. }
  364. /// <summary>
  365. ///
  366. /// </summary>
  367. /// <param name="get"></param>
  368. /// <param name="post"></param>
  369. /// <returns></returns>
  370. protected string HttpPost(string[,] get, string[,] post)
  371. {
  372. return HttpPost(get, post, ActionOptions.None);
  373. }
  374. /// <summary>
  375. /// Performs a HTTP request
  376. /// </summary>
  377. /// <param name="request"></param>
  378. /// <param name="options"></param>
  379. /// <returns>Text received</returns>
  380. protected string HttpGet(string[,] request, ActionOptions options)
  381. {
  382. string url = BuildUrl(request, options);
  383. lastGetUrl = url;
  384. return HttpGet(url);
  385. }
  386. /// <summary>
  387. /// Performs a HTTP request
  388. /// </summary>
  389. /// <param name="request"></param>
  390. /// <returns>Text received</returns>
  391. protected string HttpGet(string[,] request)
  392. {
  393. return HttpGet(request, ActionOptions.None);
  394. }
  395. /// <summary>
  396. /// Performs a HTTP request
  397. /// </summary>
  398. /// <param name="url"></param>
  399. /// <returns>Text received</returns>
  400. public string HttpGet(string url)
  401. {
  402. return GetResponseString(CreateRequest(url));
  403. }
  404. #endregion
  405. #region Login / user props
  406. public void Login(string username, string password)
  407. {
  408. if (string.IsNullOrEmpty(username)) throw new ArgumentException("Username required", "username");
  409. //if (string.IsNullOrEmpty(password)) throw new ArgumentException("Password required", "password");
  410. Reset();
  411. User = new UserInfo(); // we don't know for sure what will be our status in case of exception
  412. string result = HttpPost(new[,] { { "action", "login" } },
  413. new[,]
  414. {
  415. { "lgname", username },
  416. { "lgpassword", password }
  417. }
  418. );
  419. XmlReader xr = XmlReader.Create(new StringReader(result));
  420. xr.ReadToFollowing("login");
  421. if (xr.GetAttribute("result").Equals("NeedToken", StringComparison.InvariantCultureIgnoreCase))
  422. {
  423. AdjustCookies();
  424. string token = xr.GetAttribute("token");
  425. result = HttpPost(new[,] { { "action", "login" } },
  426. new[,]
  427. {
  428. {"lgname", username},
  429. {"lgpassword", password},
  430. {"lgtoken", token}
  431. }
  432. );
  433. xr = XmlReader.Create(new StringReader(result));
  434. xr.ReadToFollowing("login");
  435. }
  436. string status = xr.GetAttribute("result");
  437. if (!status.Equals("Success", StringComparison.InvariantCultureIgnoreCase))
  438. {
  439. throw new LoginException(this, status);
  440. }
  441. CheckForErrors(result, "login");
  442. AdjustCookies();
  443. RefreshUserInfo();
  444. }
  445. public void Logout()
  446. {
  447. Reset();
  448. User = new UserInfo();
  449. string result = HttpGet(new[,] { { "action", "logout" } });
  450. CheckForErrors(result, "logout");
  451. }
  452. public void Watch(string title)
  453. {
  454. if (string.IsNullOrEmpty(title)) throw new ArgumentException("Page name required", "title");
  455. //Reset();
  456. string result = HttpPost(new[,]
  457. {
  458. {"action", "watch"},
  459. },
  460. new[,]
  461. {
  462. {"title", title}
  463. },
  464. ActionOptions.None);
  465. CheckForErrors(result, "watch");
  466. }
  467. public void Unwatch(string title)
  468. {
  469. if (string.IsNullOrEmpty(title)) throw new ArgumentException("Page name required", "title");
  470. //Reset();
  471. string result = HttpPost(new[,]
  472. {
  473. {"action", "watch"},
  474. },
  475. new[,]
  476. {
  477. {"title", title},
  478. {"unwatch", null}
  479. });
  480. CheckForErrors(result, "watch");
  481. }
  482. public UserInfo User { get; private set; }
  483. public void RefreshUserInfo()
  484. {
  485. Reset();
  486. User = new UserInfo();
  487. string result = HttpPost(new[,] { { "action", "query" } },
  488. new[,] {
  489. { "meta", "userinfo" },
  490. { "uiprop", "blockinfo|hasmsg|groups|rights" }
  491. });
  492. var xml = CheckForErrors(result, "userinfo");
  493. User = new UserInfo(xml);
  494. }
  495. #endregion
  496. #region Page modification
  497. /// <summary>
  498. /// Opens the wiki page for editing
  499. /// </summary>
  500. /// <param name="title">The wiki page title</param>
  501. /// <returns>The current content of the wiki page</returns>
  502. public string Open(string title)
  503. {
  504. return Open(title, false);
  505. }
  506. /// <summary>
  507. /// Opens the wiki page for editing
  508. /// </summary>
  509. /// <param name="title">The wiki page title</param>
  510. /// <param name="resolveRedirects"></param>
  511. /// <returns>The current content of the wiki page</returns>
  512. public string Open(string title, bool resolveRedirects)
  513. {
  514. if (string.IsNullOrEmpty(title))
  515. throw new ArgumentException("Page name required", "title");
  516. if (!User.IsLoggedIn)
  517. throw new LoggedOffException(this);
  518. Reset();
  519. // action=query&prop=info|revisions&intoken=edit&titles=Main%20Page&rvprop=timestamp|user|comment|content
  520. string result = HttpGet(new[,]
  521. {
  522. {"action", "query"},
  523. {"prop", "info|revisions"},
  524. {"intoken", "edit"},
  525. {"titles", title},
  526. {"inprop", "protection|watched"},
  527. {"rvprop", "content|timestamp"}, // timestamp|user|comment|
  528. {resolveRedirects ? "redirects" : null, null}
  529. },
  530. ActionOptions.All);
  531. CheckForErrors(result, "query");
  532. try
  533. {
  534. Page = new PageInfo(result);
  535. Action = "edit";
  536. }
  537. catch (Exception ex)
  538. {
  539. throw new BrokenXmlException(this, ex);
  540. }
  541. return Page.Text;
  542. }
  543. public SaveInfo Save(string pageText, string summary, bool minor, WatchOptions watch)
  544. {
  545. if (string.IsNullOrEmpty(pageText) && !Page.Exists) throw new ArgumentException("Can't save empty pages", "pageText");
  546. //if (string.IsNullOrEmpty(summary)) throw new ArgumentException("Edit summary required", "summary");
  547. if (Action != "edit") throw new ApiException(this, "This page is not opened properly for editing");
  548. if (string.IsNullOrEmpty(Page.EditToken)) throw new ApiException(this, "Edit token is needed to edit pages");
  549. pageText = Tools.ConvertFromLocalLineEndings(pageText);
  550. string result = HttpPost(
  551. new[,]
  552. {
  553. { "action", "edit" },
  554. { "title", Page.Title },
  555. { minor ? "minor" : null, null },
  556. { WatchOptionsToParam(watch), null },
  557. { User.IsBot ? "bot" : null, null }
  558. },
  559. new[,]
  560. {// order matters here - https://bugzilla.wikimedia.org/show_bug.cgi?id=14210#c4
  561. { "md5", MD5(pageText) },
  562. { "summary", summary },
  563. { "basetimestamp", Page.Timestamp },
  564. { "text", pageText },
  565. { "starttimestamp", Page.TokenTimestamp },
  566. { "token", Page.EditToken }
  567. },
  568. ActionOptions.All);
  569. var xml = CheckForErrors(result, "edit");
  570. Reset();
  571. return new SaveInfo(xml);
  572. }
  573. public void Delete(string title, string reason)
  574. {
  575. Delete(title, reason, false);
  576. }
  577. public void Delete(string title, string reason, bool watch)
  578. {
  579. if (string.IsNullOrEmpty(title)) throw new ArgumentException("Page name required", "title");
  580. if (string.IsNullOrEmpty(reason)) throw new ArgumentException("Deletion reason required", "reason");
  581. Reset();
  582. Action = "delete";
  583. string result = HttpGet(
  584. new[,]
  585. {
  586. { "action", "query" },
  587. { "prop", "info" },
  588. { "intoken", "delete" },
  589. { "titles", title },
  590. //{ User.IsBot ? "bot" : null, null },
  591. { watch ? "watch" : null, null }
  592. },
  593. ActionOptions.All);
  594. CheckForErrors(result);
  595. try
  596. {
  597. XmlReader xr = XmlReader.Create(new StringReader(result));
  598. if (!xr.ReadToFollowing("page")) throw new Exception("Cannot find <page> element");
  599. Page.EditToken = xr.GetAttribute("deletetoken");
  600. }
  601. catch (Exception ex)
  602. {
  603. throw new BrokenXmlException(this, ex);
  604. }
  605. if (Aborting) throw new AbortedException(this);
  606. result = HttpPost(
  607. new[,]
  608. {
  609. { "action", "delete" }
  610. },
  611. new[,]
  612. {
  613. { "title", title },
  614. { "token", Page.EditToken },
  615. { "reason", reason }
  616. },
  617. ActionOptions.All);
  618. CheckForErrors(result);
  619. Reset();
  620. }
  621. public void Protect(string title, string reason, TimeSpan expiry, string edit, string move)
  622. {
  623. Protect(title, reason, expiry.ToString(), edit, move, false, false);
  624. }
  625. public void Protect(string title, string reason, string expiry, string edit, string move)
  626. {
  627. Protect(title, reason, expiry, edit, move, false, false);
  628. }
  629. public void Protect(string title, string reason, TimeSpan expiry, string edit, string move, bool cascade, bool watch)
  630. {
  631. Protect(title, reason, expiry.ToString(), edit, move, cascade, watch);
  632. }
  633. public void Protect(string title, string reason, string expiry, string edit, string move, bool cascade, bool watch)
  634. {
  635. if (string.IsNullOrEmpty(title)) throw new ArgumentException("Page name required", "title");
  636. if (string.IsNullOrEmpty(reason)) throw new ArgumentException("Deletion reason required", "reason");
  637. Reset();
  638. Action = "protect";
  639. string result = HttpGet(
  640. new[,]
  641. {
  642. { "action", "query" },
  643. { "prop", "info" },
  644. { "intoken", "protect" },
  645. { "titles", title },
  646. },
  647. ActionOptions.All);
  648. CheckForErrors(result);
  649. try
  650. {
  651. XmlReader xr = XmlReader.Create(new StringReader(result));
  652. if (!xr.ReadToFollowing("page")) throw new Exception("Cannot find <page> element");
  653. Page.EditToken = xr.GetAttribute("protecttoken");
  654. }
  655. catch (Exception ex)
  656. {
  657. throw new BrokenXmlException(this, ex);
  658. }
  659. if (Aborting) throw new AbortedException(this);
  660. result = HttpPost(
  661. new[,]
  662. {
  663. {"action", "protect"}
  664. },
  665. new[,]
  666. {
  667. { "title", title },
  668. { "token", Page.EditToken },
  669. { "reason", reason },
  670. { "protections", "edit=" + edit + "|move=" + move },
  671. { string.IsNullOrEmpty(expiry) ? "" : "expiry", string.IsNullOrEmpty(expiry) ? "" : expiry + "|" + expiry },
  672. { cascade ? "cascade" : null, null },
  673. //{ User.IsBot ? "bot" : null, null },
  674. { watch ? "watch" : null, null }
  675. },
  676. ActionOptions.All);
  677. CheckForErrors(result);
  678. Reset();
  679. }
  680. //Todo: Remove duplication against void Move(string title, string newTitle, string reason, bool moveTalk, bool noRedirect, bool watch)
  681. public void Move(int titleid, string newTitle, string reason, bool moveTalk, bool noRedirect, bool watch)
  682. {
  683. if (string.IsNullOrEmpty(newTitle)) throw new ArgumentException("Target page title required", "newTitle");
  684. if (string.IsNullOrEmpty(reason)) throw new ArgumentException("Page rename reason required", "reason");
  685. Reset();
  686. Action = "move";
  687. string result = HttpGet(
  688. new[,]
  689. {
  690. { "action", "query" },
  691. { "prop", "info" },
  692. { "intoken", "move" },
  693. { "titles", newTitle },
  694. },
  695. ActionOptions.All);
  696. CheckForErrors(result);
  697. bool invalid;
  698. try
  699. {
  700. XmlReader xr = XmlReader.Create(new StringReader(result));
  701. if (!xr.ReadToFollowing("page")) throw new Exception("Cannot find <page> element");
  702. invalid = xr.MoveToAttribute("invalid");
  703. Page.EditToken = xr.GetAttribute("movetoken");
  704. }
  705. catch (Exception ex)
  706. {
  707. throw new BrokenXmlException(this, ex);
  708. }
  709. if (Aborting) throw new AbortedException(this);
  710. if (invalid) throw new ApiException(this, "invalidnewtitle", new ArgumentException("Target page invalid", "newTitle"));
  711. result = HttpPost(
  712. new[,]
  713. {
  714. { "action", "move" }
  715. },
  716. new[,]
  717. {
  718. { "fromid", titleid.ToString() },
  719. { "to", newTitle },
  720. { "token", Page.EditToken },
  721. { "reason", reason },
  722. { "protections", "" },
  723. { moveTalk ? "movetalk" : null, null },
  724. { noRedirect ? "noredirect" : null, null },
  725. { User.IsBot ? "bot" : null, null },
  726. { watch ? "watch" : null, null }
  727. },
  728. ActionOptions.All);
  729. CheckForErrors(result);
  730. Reset();
  731. }
  732. public void Move(string title, string newTitle, string reason)
  733. {
  734. Move(title, newTitle, reason, true, false, false);
  735. }
  736. public void Move(string title, string newTitle, string reason, bool moveTalk, bool noRedirect)
  737. {
  738. Move(title, newTitle, reason, moveTalk, noRedirect, false);
  739. }
  740. public void Move(string title, string newTitle, string reason, bool moveTalk, bool noRedirect, bool watch)
  741. {
  742. if (string.IsNullOrEmpty(title)) throw new ArgumentException("Page title required", "title");
  743. if (string.IsNullOrEmpty(newTitle)) throw new ArgumentException("Target page title required", "newTitle");
  744. if (string.IsNullOrEmpty(reason)) throw new ArgumentException("Page rename reason required", "reason");
  745. if (title == newTitle) throw new ArgumentException("Page cannot be moved to the same title");
  746. Reset();
  747. Action = "move";
  748. string result = HttpGet(
  749. new[,]
  750. {
  751. { "action", "query" },
  752. { "prop", "info" },
  753. { "intoken", "move" },
  754. { "titles", title + "|" + newTitle },
  755. },
  756. ActionOptions.All);
  757. CheckForErrors(result);
  758. bool invalid;
  759. try
  760. {
  761. XmlReader xr = XmlReader.Create(new StringReader(result));
  762. if (!xr.ReadToFollowing("page")) throw new Exception("Cannot find <page> element");
  763. invalid = xr.MoveToAttribute("invalid");
  764. Page.EditToken = xr.GetAttribute("movetoken");
  765. }
  766. catch (Exception ex)
  767. {
  768. throw new BrokenXmlException(this, ex);
  769. }
  770. if (Aborting) throw new AbortedException(this);
  771. if (invalid) throw new ApiException(this, "invalidnewtitle", new ArgumentException("Target page invalid", "newTitle"));
  772. result = HttpPost(
  773. new[,]
  774. {
  775. { "action", "move" }
  776. },
  777. new[,]
  778. {
  779. { "from", title },
  780. { "to", newTitle },
  781. { "token", Page.EditToken },
  782. { "reason", reason },
  783. { "protections", "" },
  784. { moveTalk ? "movetalk" : null, null },
  785. { noRedirect ? "noredirect" : null, null },
  786. //{ User.IsBot ? "bot" : null, null },
  787. { watch ? "watch" : null, null }
  788. },
  789. ActionOptions.All);
  790. CheckForErrors(result);
  791. Reset();
  792. }
  793. #endregion
  794. #region Query Api
  795. public string QueryApi(string queryParamters)
  796. {
  797. if (string.IsNullOrEmpty(queryParamters)) throw new ArgumentException("queryParamters cannot be null/empty", "queryParamters");
  798. string result = HttpGet(ApiURL + "?action=query&format=xml&" + queryParamters); //Should we be checking for maxlag?
  799. CheckForErrors(result, "query");
  800. return result;
  801. }
  802. #endregion
  803. #region Wikitext operations
  804. private string ExpandRelativeUrls(string html)
  805. {
  806. return html.Replace(" href=\"/", " href=\"" + Server + "/")
  807. .Replace(" src=\"/", " src=\"" + Server + "/");
  808. }
  809. private static readonly Regex ExtractCssAndJs = new Regex(@"("
  810. + @"<!--\[if .*?-->"
  811. + @"|<style\b.*?>.*?</style>"
  812. + @"|<link rel=""stylesheet"".*?/\s?>"
  813. //+ @"|<script type=""text/javascript"".*?</script>"
  814. + ")",
  815. RegexOptions.Singleline | RegexOptions.Compiled);
  816. /// <summary>
  817. /// Loads wiki's UI HTML and scrapes everything we need to make correct previews
  818. /// </summary>
  819. private void EnsureHtmlHeadersLoaded()
  820. {
  821. if (!string.IsNullOrEmpty(HtmlHeaders)) return;
  822. string result = HttpGet(
  823. new[,]
  824. {
  825. {"action", "parse"},
  826. {"prop", "headhtml"}
  827. },
  828. ActionOptions.None
  829. );
  830. result = Tools.StringBetween(Tools.UnescapeXML(result), "<head>", "</head>");
  831. StringBuilder extracted = new StringBuilder(2048);
  832. foreach (Match m in ExtractCssAndJs.Matches(result))
  833. {
  834. extracted.Append(m.Value);
  835. extracted.Append("\n");
  836. }
  837. HtmlHeaders = ExpandRelativeUrls(extracted.ToString());
  838. }
  839. public string Preview(string title, string text)
  840. {
  841. EnsureHtmlHeadersLoaded();
  842. string result = HttpPost(
  843. new[,]
  844. {
  845. { "action", "parse" },
  846. { "prop", "text" }
  847. },
  848. new[,]
  849. {
  850. { "title", title },
  851. { "text", text },
  852. { "disablepp", null }
  853. });
  854. CheckForErrors(result, "parse");
  855. try
  856. {
  857. XmlReader xr = XmlReader.Create(new StringReader(result));
  858. if (!xr.ReadToFollowing("text")) throw new Exception("Cannot find <text> element");
  859. return ExpandRelativeUrls(xr.ReadString());
  860. }
  861. catch (Exception ex)
  862. {
  863. throw new BrokenXmlException(this, ex);
  864. }
  865. }
  866. public void Rollback(string title, string user)
  867. {
  868. string result = HttpGet(
  869. new[,]
  870. {
  871. { "action", "query" },
  872. { "prop", "revisions" },
  873. { "rvtoken", "rollback" },
  874. { "titles", title },
  875. },
  876. ActionOptions.All);
  877. CheckForErrors(result, "query");
  878. XmlReader xr = XmlReader.Create(new StringReader(result));
  879. if (!xr.ReadToFollowing("page")) throw new Exception("Cannot find <page> element");
  880. string rollbackToken = xr.GetAttribute("rollbacktoken");
  881. result = HttpPost(
  882. new[,]
  883. {
  884. {"action", "rollback"}
  885. },
  886. new[,]
  887. {
  888. {"title", title},
  889. {"token", rollbackToken},
  890. });
  891. CheckForErrors(result, "rollback");
  892. }
  893. public string ExpandTemplates(string title, string text)
  894. {
  895. string result = HttpPost(
  896. new[,]
  897. {
  898. { "action", "expandtemplates" }
  899. },
  900. new[,]
  901. {
  902. { "title", title },
  903. { "text", text }
  904. });
  905. CheckForErrors(result, "expandtemplates");
  906. try
  907. {
  908. XmlReader xr = XmlReader.Create(new StringReader(result));
  909. if (!xr.ReadToFollowing("expandtemplates")) throw new Exception("Cannot find <expandtemplates> element");
  910. return xr.ReadString();
  911. }
  912. catch (Exception ex)
  913. {
  914. throw new BrokenXmlException(this, ex);
  915. }
  916. }
  917. #endregion
  918. #region Error handling
  919. /// <summary>
  920. /// Checks the XML returned by the server for error codes and throws an appropriate exception
  921. /// </summary>
  922. /// <param name="xml">Server output</param>
  923. private XmlDocument CheckForErrors(string xml)
  924. {
  925. return CheckForErrors(xml, null);
  926. }
  927. private static readonly Regex MaxLag = new Regex(@": (\d+) seconds lagged", RegexOptions.Compiled | RegexOptions.IgnoreCase);
  928. /// <summary>
  929. /// Checks the XML returned by the server for error codes and throws an appropriate exception
  930. /// </summary>
  931. /// <param name="xml">Server output</param>
  932. /// <param name="action">The action performed, null if don't check</param>
  933. private XmlDocument CheckForErrors(string xml, string action)
  934. {
  935. if (string.IsNullOrEmpty(xml)) throw new ApiBlankException(this);
  936. var doc = new XmlDocument();
  937. try
  938. {
  939. doc.Load(new StringReader(xml));
  940. }
  941. catch (XmlException xe)
  942. {
  943. Tools.WriteDebug("ApiEdit::CheckForErrors", xml);
  944. string postParams = "";
  945. if (lastPostParameters != null )
  946. {
  947. int length = lastPostParameters.GetUpperBound(0);
  948. for (int i = 0; i <= length; i++)
  949. {
  950. if (lastPostParameters[i, 0].Contains("password") || lastPostParameters[i, 0].Contains("token"))
  951. {
  952. lastPostParameters[i, 1] = "<removed>";
  953. }
  954. }
  955. postParams = BuildQuery(lastPostParameters);
  956. }
  957. throw new ApiXmlException(this, xe, lastGetUrl, postParams, xml);
  958. }
  959. //TODO: can't figure out the best time for this check
  960. bool prevMessages = User.HasMessages;
  961. User.Update(doc);
  962. if (action != "login"
  963. && action != "userinfo"
  964. && NewMessageThrows
  965. && User.HasMessages
  966. && !prevMessages)
  967. throw new NewMessagesException(this);
  968. var errors = doc.GetElementsByTagName("error");
  969. if (errors.Count > 0)
  970. {
  971. var error = errors[0];
  972. string errorCode = error.Attributes["code"].Value;
  973. string errorMessage = error.Attributes["info"].Value;
  974. switch (errorCode.ToLower())
  975. {
  976. case "maxlag": //guessing
  977. int maxlag;
  978. int.TryParse(MaxLag.Match(xml).Groups[1].Value, out maxlag);
  979. throw new MaxlagException(this, maxlag, 10);
  980. case "wrnotloggedin":
  981. throw new LoggedOffException(this);
  982. case "spamdetected":
  983. throw new SpamlistException(this, errorMessage);
  984. //case "confirmemail":
  985. //
  986. default:
  987. if (errorCode.Contains("disabled"))
  988. {
  989. new FeatureDisabledException(this, errorCode, errorMessage);
  990. }
  991. throw new ApiErrorException(this, errorCode, errorMessage);
  992. }
  993. }
  994. if (string.IsNullOrEmpty(action)) return doc; // no action to check
  995. var api = doc["api"];
  996. if (api == null) return doc;
  997. //FIXME: Awful code is awful
  998. var page = api.GetElementsByTagName("page");
  999. if (page.Count > 0 && page[0].Attributes != null && page[0].Attributes["invalid"] != null && page[0].Attributes["invalid"].Value == "")
  1000. throw new InvalidTitleException(this, page[0].Attributes["title"].Value);
  1001. if (api.GetElementsByTagName("interwiki").Count > 0)
  1002. throw new InterwikiException(this);
  1003. var actionElement = api[action];
  1004. if (actionElement == null) return doc; // or shall we explode?
  1005. if (actionElement.HasAttribute("assert"))
  1006. {
  1007. string what = actionElement.GetAttribute("assert");
  1008. if (what == "user")
  1009. throw new LoggedOffException(this);
  1010. throw new AssertionFailedException(this, what);
  1011. }
  1012. if (actionElement.HasAttribute("spamblacklist"))
  1013. {
  1014. throw new SpamlistException(this, actionElement.GetAttribute("spamblacklist"));
  1015. }
  1016. if (actionElement.GetElementsByTagName("captcha").Count > 0)
  1017. {
  1018. throw new CaptchaException(this);
  1019. }
  1020. string result = actionElement.GetAttribute("result");
  1021. if (!string.IsNullOrEmpty(result) && result != "Success")
  1022. {
  1023. throw new OperationFailedException(this, action, result, xml);
  1024. }
  1025. return doc;
  1026. }
  1027. #endregion
  1028. #region Helpers
  1029. /// <summary>
  1030. ///
  1031. /// </summary>
  1032. /// <param name="value"></param>
  1033. /// <returns></returns>
  1034. protected static string BoolToParam(bool value)
  1035. {
  1036. return value ? "1" : "0";
  1037. }
  1038. protected static string WatchOptionsToParam(WatchOptions watch)
  1039. {
  1040. // Here we provide options for both 1.16 and older versions
  1041. switch (watch)
  1042. {
  1043. case WatchOptions.UsePreferences:
  1044. return "watchlist=preferences";
  1045. case WatchOptions.Watch:
  1046. return "watchlist=watch&watch";
  1047. case WatchOptions.Unwatch:
  1048. return "watchlist=unwatch&unwatch";
  1049. default:
  1050. return "watchlist=nochange";
  1051. }
  1052. }
  1053. /// <summary>
  1054. /// For private use, static to avoid unneeded reinitialisation
  1055. /// </summary>
  1056. private static readonly System.Security.Cryptography.MD5 Md5Summer = System.Security.Cryptography.MD5.Create();
  1057. /// <summary>
  1058. ///
  1059. /// </summary>
  1060. /// <param name="input"></param>
  1061. /// <returns></returns>
  1062. protected static string MD5(string input)
  1063. {
  1064. return MD5(Encoding.UTF8.GetBytes(input));
  1065. }
  1066. /// <summary>
  1067. ///
  1068. /// </summary>
  1069. /// <param name="input"></param>
  1070. /// <returns></returns>
  1071. protected static string MD5(byte[] input)
  1072. {
  1073. byte[] hash = Md5Summer.ComputeHash(input);
  1074. StringBuilder sb = new StringBuilder(20);
  1075. for (int i = 0; i < hash.Length; i++)
  1076. {
  1077. sb.Append(hash[i].ToString("x2"));
  1078. }
  1079. return sb.ToString();
  1080. }
  1081. #endregion
  1082. }
  1083. public enum WatchOptions
  1084. {
  1085. NoChange,
  1086. UsePreferences,
  1087. Watch,
  1088. Unwatch
  1089. }
  1090. [Flags]
  1091. public enum ActionOptions
  1092. {
  1093. None = 0,
  1094. CheckMaxlag = 1,
  1095. RequireLogin = 2,
  1096. CheckNewMessages = 4,
  1097. All = CheckMaxlag | RequireLogin | CheckNewMessages
  1098. };
  1099. }