Paste.php 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. <?php
  2. /**
  3. * PrivateBin
  4. *
  5. * a zero-knowledge paste bin
  6. *
  7. * @link https://github.com/PrivateBin/PrivateBin
  8. * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  9. * @license https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
  10. * @version 1.2.1
  11. */
  12. namespace PrivateBin\Model;
  13. use Exception;
  14. use PrivateBin\Controller;
  15. use PrivateBin\Filter;
  16. use PrivateBin\Persistence\ServerSalt;
  17. /**
  18. * Paste
  19. *
  20. * Model of a PrivateBin paste.
  21. */
  22. class Paste extends AbstractModel
  23. {
  24. /**
  25. * Token for challenge/response.
  26. *
  27. * @access protected
  28. * @var string
  29. */
  30. protected $_token = '';
  31. /**
  32. * Get paste data.
  33. *
  34. * @access public
  35. * @throws Exception
  36. * @return array
  37. */
  38. public function get()
  39. {
  40. // return cached result if one is found
  41. if (array_key_exists('adata', $this->_data) || array_key_exists('data', $this->_data)) {
  42. return $this->_data;
  43. }
  44. $data = $this->_store->read($this->getId());
  45. if ($data === false) {
  46. throw new Exception(Controller::GENERIC_ERROR, 64);
  47. }
  48. // check if paste has expired and delete it if neccessary.
  49. if (array_key_exists('expire_date', $data['meta'])) {
  50. if ($data['meta']['expire_date'] < time()) {
  51. $this->delete();
  52. throw new Exception(Controller::GENERIC_ERROR, 63);
  53. }
  54. // We kindly provide the remaining time before expiration (in seconds)
  55. $data['meta']['time_to_live'] = $data['meta']['expire_date'] - time();
  56. unset($data['meta']['expire_date']);
  57. }
  58. // check if non-expired burn after reading paste needs to be deleted,
  59. // but don't delete it if an incorrect token was sent
  60. if (
  61. (
  62. (array_key_exists('adata', $data) && $data['adata'][3] === 1) ||
  63. (array_key_exists('burnafterreading', $data['meta']) && $data['meta']['burnafterreading'])
  64. ) && (
  65. !array_key_exists('challenge', $data['meta']) ||
  66. $this->_token === $data['meta']['challenge']
  67. )
  68. ) {
  69. $this->delete();
  70. }
  71. // set formatter for the view in version 1 pastes.
  72. if (array_key_exists('data', $data) && !array_key_exists('formatter', $data['meta'])) {
  73. // support < 0.21 syntax highlighting
  74. if (array_key_exists('syntaxcoloring', $data['meta']) && $data['meta']['syntaxcoloring'] === true) {
  75. $data['meta']['formatter'] = 'syntaxhighlighting';
  76. } else {
  77. $data['meta']['formatter'] = $this->_conf->getKey('defaultformatter');
  78. }
  79. }
  80. // support old paste format with server wide salt
  81. if (!array_key_exists('salt', $data['meta'])) {
  82. $data['meta']['salt'] = ServerSalt::get();
  83. }
  84. $data['comments'] = array_values($this->getComments());
  85. $data['comment_count'] = count($data['comments']);
  86. $data['comment_offset'] = 0;
  87. $data['@context'] = '?jsonld=paste';
  88. $this->_data = $data;
  89. return $this->_data;
  90. }
  91. /**
  92. * Store the paste's data.
  93. *
  94. * @access public
  95. * @throws Exception
  96. */
  97. public function store()
  98. {
  99. // Check for improbable collision.
  100. if ($this->exists()) {
  101. throw new Exception('You are unlucky. Try again.', 75);
  102. }
  103. $this->_data['meta']['created'] = time();
  104. $this->_data['meta']['salt'] = serversalt::generate();
  105. // if a challenge was sent, we store the HMAC of paste ID & challenge
  106. if (array_key_exists('challenge', $this->_data['meta'])) {
  107. $this->_data['meta']['challenge'] = base64_encode(hash_hmac(
  108. 'sha256', hex2bin($this->getId()), base64_decode($this->_data['meta']['challenge']), true
  109. ));
  110. }
  111. // store paste
  112. if (
  113. $this->_store->create(
  114. $this->getId(),
  115. $this->_data
  116. ) === false
  117. ) {
  118. throw new Exception('Error saving paste. Sorry.', 76);
  119. }
  120. }
  121. /**
  122. * Delete the paste.
  123. *
  124. * @access public
  125. * @throws Exception
  126. */
  127. public function delete()
  128. {
  129. $this->_store->delete($this->getId());
  130. }
  131. /**
  132. * Test if paste exists in store.
  133. *
  134. * @access public
  135. * @return bool
  136. */
  137. public function exists()
  138. {
  139. return $this->_store->exists($this->getId());
  140. }
  141. /**
  142. * Get a comment, optionally a specific instance.
  143. *
  144. * @access public
  145. * @param string $parentId
  146. * @param string $commentId
  147. * @throws Exception
  148. * @return Comment
  149. */
  150. public function getComment($parentId, $commentId = '')
  151. {
  152. if (!$this->exists()) {
  153. throw new Exception('Invalid data.', 62);
  154. }
  155. $comment = new Comment($this->_conf, $this->_store);
  156. $comment->setPaste($this);
  157. $comment->setParentId($parentId);
  158. if ($commentId !== '') {
  159. $comment->setId($commentId);
  160. }
  161. return $comment;
  162. }
  163. /**
  164. * Get all comments, if any.
  165. *
  166. * @access public
  167. * @return array
  168. */
  169. public function getComments()
  170. {
  171. return $this->_store->readComments($this->getId());
  172. }
  173. /**
  174. * Generate the "delete" token.
  175. *
  176. * The token is the hmac of the pastes ID signed with the server salt.
  177. * The paste can be deleted by calling:
  178. * https://example.com/privatebin/?pasteid=<pasteid>&deletetoken=<deletetoken>
  179. *
  180. * @access public
  181. * @return string
  182. */
  183. public function getDeleteToken()
  184. {
  185. if (!array_key_exists('salt', $this->_data['meta'])) {
  186. $this->get();
  187. }
  188. return hash_hmac(
  189. $this->_conf->getKey('zerobincompatibility') ? 'sha1' : 'sha256',
  190. $this->getId(),
  191. $this->_data['meta']['salt']
  192. );
  193. }
  194. /**
  195. * Check if paste has discussions enabled.
  196. *
  197. * @access public
  198. * @throws Exception
  199. * @return bool
  200. */
  201. public function isOpendiscussion()
  202. {
  203. if (!array_key_exists('adata', $this->_data) && !array_key_exists('data', $this->_data)) {
  204. $this->get();
  205. }
  206. return
  207. (array_key_exists('adata', $this->_data) && $this->_data['adata'][2] === 1) ||
  208. (array_key_exists('opendiscussion', $this->_data['meta']) && $this->_data['meta']['opendiscussion']);
  209. }
  210. /**
  211. * Check if paste challenge matches provided token.
  212. *
  213. * @access public
  214. * @param string $token
  215. * @throws Exception
  216. * @return bool
  217. */
  218. public function isTokenCorrect($token)
  219. {
  220. $this->_token = $token;
  221. if (!array_key_exists('challenge', $this->_data['meta'])) {
  222. $this->get();
  223. }
  224. if (array_key_exists('challenge', $this->_data['meta'])) {
  225. return Filter::slowEquals($token, $this->_data['meta']['challenge']);
  226. }
  227. // paste created without challenge, accept every token sent
  228. return true;
  229. }
  230. /**
  231. * Check if paste salt based HMAC matches provided delete token.
  232. *
  233. * @access public
  234. * @param string $deletetoken
  235. * @throws Exception
  236. * @return bool
  237. */
  238. public function isDeleteTokenCorrect($deletetoken)
  239. {
  240. return Filter::slowEquals($deletetoken, $this->getDeleteToken());
  241. }
  242. /**
  243. * Sanitizes data to conform with current configuration.
  244. *
  245. * @access protected
  246. * @param array $data
  247. * @return array
  248. */
  249. protected function _sanitize(array $data)
  250. {
  251. $expiration = $data['meta']['expire'];
  252. unset($data['meta']['expire']);
  253. $expire_options = $this->_conf->getSection('expire_options');
  254. if (array_key_exists($expiration, $expire_options)) {
  255. $expire = $expire_options[$expiration];
  256. } else {
  257. // using getKey() to ensure a default value is present
  258. $expire = $this->_conf->getKey($this->_conf->getKey('default', 'expire'), 'expire_options');
  259. }
  260. if ($expire > 0) {
  261. $data['meta']['expire_date'] = time() + $expire;
  262. }
  263. return $data;
  264. }
  265. /**
  266. * Validate data.
  267. *
  268. * @access protected
  269. * @param array $data
  270. * @throws Exception
  271. */
  272. protected function _validate(array $data)
  273. {
  274. // reject invalid or disabled formatters
  275. if (!array_key_exists($data['adata'][1], $this->_conf->getSection('formatter_options'))) {
  276. throw new Exception('Invalid data.', 75);
  277. }
  278. // discussion requested, but disabled in config or burn after reading requested as well, or invalid integer
  279. if (
  280. ($data['adata'][2] === 1 && ( // open discussion flag
  281. !$this->_conf->getKey('discussion') ||
  282. $data['adata'][3] === 1 // burn after reading flag
  283. )) ||
  284. ($data['adata'][2] !== 0 && $data['adata'][2] !== 1)
  285. ) {
  286. throw new Exception('Invalid data.', 74);
  287. }
  288. // reject invalid burn after reading
  289. if ($data['adata'][3] !== 0 && $data['adata'][3] !== 1) {
  290. throw new Exception('Invalid data.', 73);
  291. }
  292. }
  293. }