Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.18% covered (success)
97.18%
69 / 71
80.00% covered (success)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Paste
97.18% covered (success)
97.18%
69 / 71
80.00% covered (success)
80.00%
8 / 10
36
0.00% covered (danger)
0.00%
0 / 1
 get
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
7
 store
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 delete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 exists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getComment
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getComments
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getDeleteToken
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 isOpendiscussion
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 _sanitize
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 _validate
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
9
1<?php declare(strict_types=1);
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 */
11
12namespace PrivateBin\Model;
13
14use Exception;
15use PrivateBin\Controller;
16use PrivateBin\Persistence\ServerSalt;
17
18/**
19 * Paste
20 *
21 * Model of a PrivateBin paste.
22 */
23class Paste extends AbstractModel
24{
25    /**
26     * authenticated data index of paste formatter (plaintext/syntaxhighlighting/markdown)
27     *
28     * @const int
29     */
30    const ADATA_FORMATTER = 1;
31
32    /**
33     * authenticated data index of open-discussion flag (0/1)
34     *
35     * @const int
36     */
37    const ADATA_OPEN_DISCUSSION = 2;
38
39    /**
40     * authenticated data index of burn-after-reading flag (0/1)
41     *
42     * @const int
43     */
44    const ADATA_BURN_AFTER_READING = 3;
45
46    /**
47     * Get paste data.
48     *
49     * @access public
50     * @throws Exception
51     * @return array
52     */
53    public function get()
54    {
55        $data = $this->_store->read($this->getId());
56        if ($data === false) {
57            throw new Exception(Controller::GENERIC_ERROR, 64);
58        }
59
60        // check if paste has expired and delete it if necessary.
61        if (array_key_exists('expire_date', $data['meta'])) {
62            $now = time();
63            if ($data['meta']['expire_date'] < $now) {
64                $this->delete();
65                throw new Exception(Controller::GENERIC_ERROR, 63);
66            }
67            // We kindly provide the remaining time before expiration (in seconds)
68            $data['meta']['time_to_live'] = $data['meta']['expire_date'] - $now;
69            unset($data['meta']['expire_date']);
70        }
71        if (array_key_exists('created', $data['meta'])) {
72            unset($data['meta']['created']);
73        }
74
75        // check if non-expired burn after reading paste needs to be deleted
76        if (
77            array_key_exists('adata', $data) &&
78            $data['adata'][self::ADATA_BURN_AFTER_READING] === 1
79        ) {
80            $this->delete();
81        }
82
83        $data['comments']       = array_values($this->getComments());
84        $data['comment_count']  = count($data['comments']);
85        $data['comment_offset'] = 0;
86        $data['@context']       = '?jsonld=paste';
87        $this->_data            = $data;
88
89        return $this->_data;
90    }
91
92    /**
93     * Store the paste's data.
94     *
95     * @access public
96     * @throws Exception
97     */
98    public function store()
99    {
100        // Check for improbable collision.
101        if ($this->exists()) {
102            throw new Exception('You are unlucky. Try again.', 75);
103        }
104
105        $this->_data['meta']['salt'] = ServerSalt::generate();
106
107        // store paste
108        if (
109            $this->_store->create(
110                $this->getId(),
111                $this->_data
112            ) === false
113        ) {
114            throw new Exception('Error saving document. Sorry.', 76);
115        }
116    }
117
118    /**
119     * Delete the paste.
120     *
121     * @access public
122     * @throws Exception
123     */
124    public function delete()
125    {
126        $this->_store->delete($this->getId());
127    }
128
129    /**
130     * Test if paste exists in store.
131     *
132     * @access public
133     * @return bool
134     */
135    public function exists()
136    {
137        return $this->_store->exists($this->getId());
138    }
139
140    /**
141     * Get a comment, optionally a specific instance.
142     *
143     * @access public
144     * @param string $parentId
145     * @param string $commentId
146     * @throws Exception
147     * @return Comment
148     */
149    public function getComment($parentId, $commentId = '')
150    {
151        if (!$this->exists()) {
152            throw new Exception('Invalid data.', 62);
153        }
154        $comment = new Comment($this->_conf, $this->_store);
155        $comment->setPaste($this);
156        $comment->setParentId($parentId);
157        if ($commentId !== '') {
158            $comment->setId($commentId);
159        }
160        return $comment;
161    }
162
163    /**
164     * Get all comments, if any.
165     *
166     * @access public
167     * @return array
168     */
169    public function getComments()
170    {
171        if ($this->_conf->getKey('discussiondatedisplay')) {
172            return $this->_store->readComments($this->getId());
173        }
174        return array_map(function ($comment) {
175            if (array_key_exists('created', $comment['meta'])) {
176                unset($comment['meta']['created']);
177            }
178            return $comment;
179        }, $this->_store->readComments($this->getId()));
180    }
181
182    /**
183     * Generate the "delete" token.
184     *
185     * The token is the hmac of the pastes ID signed with the server salt.
186     * The paste can be deleted by calling:
187     * https://example.com/privatebin/?pasteid=<pasteid>&deletetoken=<deletetoken>
188     *
189     * @access public
190     * @return string
191     */
192    public function getDeleteToken()
193    {
194        if (!array_key_exists('salt', $this->_data['meta'])) {
195            $this->get();
196        }
197        return hash_hmac('sha256', $this->getId(), $this->_data['meta']['salt']);
198    }
199
200    /**
201     * Check if paste has discussions enabled.
202     *
203     * @access public
204     * @throws Exception
205     * @return bool
206     */
207    public function isOpendiscussion()
208    {
209        if (!array_key_exists('adata', $this->_data) && !array_key_exists('data', $this->_data)) {
210            $this->get();
211        }
212        return array_key_exists('adata', $this->_data) &&
213            $this->_data['adata'][self::ADATA_OPEN_DISCUSSION] === 1;
214    }
215
216    /**
217     * Sanitizes data to conform with current configuration.
218     *
219     * @access protected
220     * @param  array $data
221     */
222    protected function _sanitize(array &$data)
223    {
224        $expiration = $data['meta']['expire'] ?? 0;
225        unset($data['meta']['expire']);
226        $expire_options = $this->_conf->getSection('expire_options');
227        if (array_key_exists($expiration, $expire_options)) {
228            $expire = $expire_options[$expiration];
229        } else {
230            // using getKey() to ensure a default value is present
231            $expire = $this->_conf->getKey($this->_conf->getKey('default', 'expire'), 'expire_options');
232        }
233        if ($expire > 0) {
234            $data['meta']['expire_date'] = time() + $expire;
235        }
236    }
237
238    /**
239     * Validate data.
240     *
241     * @access protected
242     * @param  array $data
243     * @throws Exception
244     */
245    protected function _validate(array &$data)
246    {
247        // reject invalid or disabled formatters
248        if (!array_key_exists($data['adata'][self::ADATA_FORMATTER], $this->_conf->getSection('formatter_options'))) {
249            throw new Exception('Invalid data.', 75);
250        }
251
252        // discussion requested, but disabled in config or burn after reading requested as well, or invalid integer
253        if (
254            ($data['adata'][self::ADATA_OPEN_DISCUSSION] === 1 && (
255                !$this->_conf->getKey('discussion') ||
256                $data['adata'][self::ADATA_BURN_AFTER_READING] === 1
257            )) ||
258            ($data['adata'][self::ADATA_OPEN_DISCUSSION] !== 0 && $data['adata'][self::ADATA_OPEN_DISCUSSION] !== 1)
259        ) {
260            throw new Exception('Invalid data.', 74);
261        }
262
263        // reject invalid burn after reading
264        if (
265            $data['adata'][self::ADATA_BURN_AFTER_READING] !== 0 &&
266            $data['adata'][self::ADATA_BURN_AFTER_READING] !== 1
267        ) {
268            throw new Exception('Invalid data.', 73);
269        }
270    }
271}