Passed
Pull Request — master (#262)
by Pascal
02:52
created

replaceAbsolutePathsHLSEncryption()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 19
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 11
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 19
rs 9.9
1
<?php
2
3
namespace ProtoneMedia\LaravelFFMpeg\Exporters;
4
5
use Closure;
6
use Illuminate\Support\Collection;
7
use Illuminate\Support\Str;
8
use ProtoneMedia\LaravelFFMpeg\FFMpeg\StdListener;
9
use ProtoneMedia\LaravelFFMpeg\Filesystem\Disk;
10
11
trait EncryptsHLSSegments
12
{
13
    /**
14
     * The encryption key.
15
     *
16
     * @var string
17
     */
18
    private $encryptionKey;
19
20
    /**
21
     * Gets called whenever a new encryption key is set.
22
     *
23
     * @var callable
24
     */
25
    private $onNewEncryptionKey;
26
27
    /**
28
     * Disk to store the secrets.
29
     *
30
     * @var \ProtoneMedia\LaravelFFMpeg\Filesystem\Disk
31
     */
32
    private $encryptionSecretsDisk;
33
34
    /**
35
     * Encryption IV
36
     *
37
     * @var string
38
     */
39
    private $encryptionIV;
40
41
    /**
42
     * Wether to rotate the key on every segment.
43
     *
44
     * @var boolean
45
     */
46
    private $rotateEncryptiongKey = false;
47
48
    /**
49
     * Number of opened segments.
50
     *
51
     * @var integer
52
     */
53
    private $segmentsOpened = 0;
54
55
    /**
56
     * Number of segments that can use the same key.
57
     *
58
     * @var integer
59
     */
60
    private $segmentsPerKey = 1;
61
62
    /**
63
     * Creates a new encryption key.
64
     *
65
     * @return string
66
     */
67
    public static function generateEncryptionKey(): string
68
    {
69
        return random_bytes(16);
70
    }
71
72
    /**
73
     * Sets the encryption key with the given value or generates a new one.
74
     *
75
     * @param string $key
76
     * @return string
77
     */
78
    private function setEncryptionKey($key = null): string
79
    {
80
        return $this->encryptionKey = $key ?: static::generateEncryptionKey();
81
    }
82
83
    /**
84
     * Initialises the disk, info and IV for encryption and sets the key.
85
     *
86
     * @param string $key
87
     * @return self
88
     */
89
    public function withEncryptionKey($key): self
90
    {
91
        $this->encryptionSecretsDisk = Disk::makeTemporaryDisk();
92
        $this->encryptionIV          = bin2hex(static::generateEncryptionKey());
93
94
        $this->setEncryptionKey($key);
95
96
        return $this;
97
    }
98
99
    /**
100
     * Enables encryption with rotating keys. The callable will receive every new
101
     * key and the integer sets the number of segments that can
102
     * use the same key.
103
     *
104
     * @param Closure $callback
105
     * @param int $segmentsPerKey
106
     * @return self
107
     */
108
    public function withRotatingEncryptionKey(Closure $callback, int $segmentsPerKey = 1): self
109
    {
110
        $this->rotateEncryptiongKey = true;
111
        $this->onNewEncryptionKey   = $callback;
112
        $this->segmentsPerKey       = $segmentsPerKey;
113
114
        return $this->withEncryptionKey(static::generateEncryptionKey());
115
    }
116
117
    /**
118
     * Rotates the key and returns the absolute path to the info file.
119
     *
120
     * @return string
121
     */
122
    private function rotateEncryptionKey(): string
123
    {
124
125
        // get the absolute path to the encryption key
126
        $keyPath = $this->encryptionSecretsDisk
127
            ->makeMedia($keyFilename = uniqid() . '.key')->getLocalPath();
128
129
        // randomize the encryption key
130
        $this->encryptionSecretsDisk->put(
0 ignored issues
show
Bug introduced by
The method put() does not exist on ProtoneMedia\LaravelFFMpeg\Filesystem\Disk. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

130
        $this->encryptionSecretsDisk->/** @scrutinizer ignore-call */ 
131
                                      put(
Loading history...
131
            $keyFilename,
132
            $encryptionKey = $this->setEncryptionKey()
133
        );
134
135
        // generate an info file with a reference to the encryption key and IV
136
        $this->encryptionSecretsDisk->put(
137
            HLSExporter::HLS_KEY_INFO_FILENAME,
138
            implode(PHP_EOL, [
139
                $keyPath, $keyPath, $this->encryptionIV,
140
            ])
141
        );
142
143
        // call the callback
144
        if ($this->onNewEncryptionKey) {
145
            call_user_func($this->onNewEncryptionKey, $keyFilename, $encryptionKey);
146
        }
147
148
        // return the absolute path to the info file
149
        return $this->encryptionSecretsDisk
150
            ->makeMedia(HLSExporter::HLS_KEY_INFO_FILENAME)
151
            ->getLocalPath();
152
    }
153
154
    /**
155
     * Returns an array with the encryption parameters.
156
     *
157
     * @return array
158
     */
159
    private function getEncrypedHLSParameters(): array
160
    {
161
        if (!$this->encryptionKey) {
162
            return [];
163
        }
164
165
        $keyInfoPath = $this->rotateEncryptionKey();
166
        $parameters  = ['-hls_key_info_file', $keyInfoPath];
167
168
        if ($this->rotateEncryptiongKey) {
169
            $parameters[] = '-hls_flags';
170
            $parameters[] = 'periodic_rekey';
171
        }
172
173
        return $parameters;
174
    }
175
176
    /**
177
     * Adds a listener and handler to rotate the key on
178
     * every new HLS segment.
179
     *
180
     * @return void
181
     */
182
    private function addHandlerToRotateEncryptionKey()
183
    {
184
        if (!$this->rotateEncryptiongKey) {
185
            return;
186
        }
187
188
        $this->addListener(new StdListener)->onEvent('listen', function ($line) {
0 ignored issues
show
Bug introduced by
It seems like addListener() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

188
        $this->/** @scrutinizer ignore-call */ 
189
               addListener(new StdListener)->onEvent('listen', function ($line) {
Loading history...
189
            $opensEncryptedSegment = Str::contains($line, "Opening 'crypto:/")
190
                && Str::contains($line, ".ts' for writing");
191
192
            if (!$opensEncryptedSegment) {
193
                return;
194
            }
195
196
            $this->segmentsOpened++;
197
198
            if ($this->segmentsOpened % $this->segmentsPerKey === 0) {
199
                $this->rotateEncryptionKey();
200
            }
201
        });
202
    }
203
204
    /**
205
     * While encoding, the encryption keys are saved to a temporary directory.
206
     * With this method, we loop through all segment playlists and replace
207
     * the absolute path to the keys to a relative ones.
208
     *
209
     * @param \Illuminate\Support\Collection $playlistMedia
210
     * @return void
211
     */
212
    private function replaceAbsolutePathsHLSEncryption(Collection $playlistMedia)
213
    {
214
        if (!$this->encryptionSecretsDisk) {
215
            return;
216
        }
217
218
        $playlistMedia->each(function ($playlistMedia) {
219
            $disk = $playlistMedia->getDisk();
220
            $path = $playlistMedia->getPath();
221
222
            $prefix = '#EXT-X-KEY:METHOD=AES-128,URI="';
223
224
            $content = str_replace(
225
                $prefix . $this->encryptionSecretsDisk->path(''),
0 ignored issues
show
Bug introduced by
The method path() does not exist on ProtoneMedia\LaravelFFMpeg\Filesystem\Disk. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

225
                $prefix . $this->encryptionSecretsDisk->/** @scrutinizer ignore-call */ path(''),
Loading history...
226
                $prefix,
227
                $disk->get($path)
228
            );
229
230
            $disk->put($path, $content);
231
        });
232
    }
233
234
    /**
235
     * Removes the encryption keys from the temporary disk.
236
     *
237
     * @return void
238
     */
239
    private function cleanupHLSEncryption()
240
    {
241
        if (!$this->encryptionSecretsDisk) {
242
            return;
243
        }
244
245
        $paths = $this->encryptionSecretsDisk->allFiles();
0 ignored issues
show
Bug introduced by
The method allFiles() does not exist on ProtoneMedia\LaravelFFMpeg\Filesystem\Disk. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

245
        /** @scrutinizer ignore-call */ 
246
        $paths = $this->encryptionSecretsDisk->allFiles();
Loading history...
246
247
        foreach ($paths as $path) {
248
            $this->encryptionSecretsDisk->delete($path);
0 ignored issues
show
Bug introduced by
The method delete() does not exist on ProtoneMedia\LaravelFFMpeg\Filesystem\Disk. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

248
            $this->encryptionSecretsDisk->/** @scrutinizer ignore-call */ 
249
                                          delete($path);
Loading history...
249
        }
250
    }
251
}
252