Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.06% covered (success)
97.06%
66 / 68
80.00% covered (success)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Paste
97.06% covered (success)
97.06%
66 / 68
80.00% covered (success)
80.00%
8 / 10
33
0.00% covered (danger)
0.00%
0 / 1
 get
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 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%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 _sanitize
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 _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 PrivateBin\Controller;
15use PrivateBin\Exception\TranslatedException;
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 TranslatedException
51     * @return array
52     */
53    public function get()
54    {
55        $data = $this->_store->read($this->getId());
56        if ($data === false) {
57            throw new TranslatedException(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 TranslatedException(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 (($data['adata'][self::ADATA_BURN_AFTER_READING] ?? 0) === 1) {
77            $this->delete();
78        }
79
80        $data['comments']       = array_values($this->getComments());
81        $data['comment_count']  = count($data['comments']);
82        $data['comment_offset'] = 0;
83        $data['@context']       = '?jsonld=paste';
84        $this->_data            = $data;
85
86        return $this->_data;
87    }
88
89    /**
90     * Store the paste's data.
91     *
92     * @access public
93     * @throws TranslatedException
94     */
95    public function store()
96    {
97        // Check for improbable collision.
98        if ($this->exists()) {
99            throw new TranslatedException(self::COLLISION_ERROR, 75);
100        }
101
102        $this->_data['meta']['salt'] = ServerSalt::generate();
103
104        // store paste
105        if (
106            $this->_store->create(
107                $this->getId(),
108                $this->_data
109            ) === false
110        ) {
111            throw new TranslatedException('Error saving document. Sorry.', 76);
112        }
113    }
114
115    /**
116     * Delete the paste.
117     *
118     * @access public
119     */
120    public function delete()
121    {
122        $this->_store->delete($this->getId());
123    }
124
125    /**
126     * Test if paste exists in store.
127     *
128     * @access public
129     * @return bool
130     */
131    public function exists()
132    {
133        return $this->_store->exists($this->getId());
134    }
135
136    /**
137     * Get a comment, optionally a specific instance.
138     *
139     * @access public
140     * @param string $parentId
141     * @param string $commentId
142     * @throws TranslatedException
143     * @return Comment
144     */
145    public function getComment($parentId, $commentId = '')
146    {
147        if (!$this->exists()) {
148            throw new TranslatedException(self::INVALID_DATA_ERROR, 62);
149        }
150        $comment = new Comment($this->_conf, $this->_store);
151        $comment->setPaste($this);
152        $comment->setParentId($parentId);
153        if (!empty($commentId)) {
154            $comment->setId($commentId);
155        }
156        return $comment;
157    }
158
159    /**
160     * Get all comments, if any.
161     *
162     * @access public
163     * @return array
164     */
165    public function getComments()
166    {
167        if ($this->_conf->getKey('discussiondatedisplay')) {
168            return $this->_store->readComments($this->getId());
169        }
170        return array_map(function ($comment) {
171            if (array_key_exists('created', $comment['meta'])) {
172                unset($comment['meta']['created']);
173            }
174            return $comment;
175        }, $this->_store->readComments($this->getId()));
176    }
177
178    /**
179     * Generate the "delete" token.
180     *
181     * The token is the hmac of the pastes ID signed with the server salt.
182     * The paste can be deleted by calling:
183     * https://example.com/privatebin/?pasteid=<pasteid>&deletetoken=<deletetoken>
184     *
185     * @access public
186     * @return string
187     */
188    public function getDeleteToken()
189    {
190        if (!array_key_exists('salt', $this->_data['meta'])) {
191            $this->get();
192        }
193        return hash_hmac('sha256', $this->getId(), $this->_data['meta']['salt']);
194    }
195
196    /**
197     * Check if paste has discussions enabled.
198     *
199     * @access public
200     * @return bool
201     */
202    public function isOpendiscussion()
203    {
204        if (!array_key_exists('adata', $this->_data) && !array_key_exists('data', $this->_data)) {
205            $this->get();
206        }
207        return ($this->_data['adata'][self::ADATA_OPEN_DISCUSSION] ?? 0) === 1;
208    }
209
210    /**
211     * Sanitizes data to conform with current configuration.
212     *
213     * @access protected
214     * @param  array $data
215     */
216    protected function _sanitize(array &$data)
217    {
218        $expiration = $data['meta']['expire'] ?? 0;
219        unset($data['meta']['expire']);
220        $expire_options = $this->_conf->getSection('expire_options');
221        // using getKey() to ensure a default value is present
222        $expire = $expire_options[$expiration] ??
223            $this->_conf->getKey($this->_conf->getKey('default', 'expire'), 'expire_options');
224        if ($expire > 0) {
225            $data['meta']['expire_date'] = time() + $expire;
226        }
227    }
228
229    /**
230     * Validate data.
231     *
232     * @access protected
233     * @param  array $data
234     * @throws TranslatedException
235     */
236    protected function _validate(array &$data)
237    {
238        // reject invalid or disabled formatters
239        if (!array_key_exists($data['adata'][self::ADATA_FORMATTER], $this->_conf->getSection('formatter_options'))) {
240            throw new TranslatedException(self::INVALID_DATA_ERROR, 75);
241        }
242
243        // discussion requested, but disabled in config or burn after reading requested as well, or invalid integer
244        if (
245            ($data['adata'][self::ADATA_OPEN_DISCUSSION] === 1 && (
246                !$this->_conf->getKey('discussion') ||
247                $data['adata'][self::ADATA_BURN_AFTER_READING] === 1
248            )) ||
249            ($data['adata'][self::ADATA_OPEN_DISCUSSION] !== 0 && $data['adata'][self::ADATA_OPEN_DISCUSSION] !== 1)
250        ) {
251            throw new TranslatedException(self::INVALID_DATA_ERROR, 74);
252        }
253
254        // reject invalid burn after reading
255        if (
256            $data['adata'][self::ADATA_BURN_AFTER_READING] !== 0 &&
257            $data['adata'][self::ADATA_BURN_AFTER_READING] !== 1
258        ) {
259            throw new TranslatedException(self::INVALID_DATA_ERROR, 73);
260        }
261    }
262}