ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
DownloadContainerAsZip.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
21namespace ILIAS\File\Activities;
22
25use ILIAS\Data\Factory as DataFactory;
30use ILIAS\UI\Component\Input\Factory as InputFactory;
34
48{
49 private const int MAX_FILES = 500;
50
55 private const int MAX_SIZE = 100 * 1024 * 1024;
56
57 public function __construct(
58 private readonly ListFiles $list_files,
59 private readonly DeliverFileObject $deliver_file,
60 private readonly FilePayloadFactory $payloads,
61 private readonly Parameter $parameter,
62 private readonly DataFactory $data = new DataFactory(),
63 ) {
64 }
65
67 {
68 return $this->data->text()->markdown()->simpleDocument(
69 "Packs the files of a container into a zip archive.\n\n"
70 . "Only files the user may read end up in it, at most " . self::MAX_FILES . " of them and "
71 . self::MAX_SIZE . " bytes together. "
72 . "Files whose content cannot be read are left out and listed in `skipped`. "
73 . "The archive is returned base64 encoded; over REST `?raw=1` gives the zip itself."
74 );
75 }
76
77 public function getInputDescription(FieldFactory $f): FormInput
78 {
79 return $f->section(
80 [
81 'parent_ref_id' => $f->numeric('Container', 'Reference id of the container to pack.')
82 ->withRequired(true)
83 ->withDedicatedName('parent_ref_id'),
84 'recursive' => $f->checkbox(
85 'Recursive',
86 'Also pack the files of the containers below this one.'
87 )->withDedicatedName('recursive'),
88 ],
89 'Download a container as zip'
90 );
91 }
92
94 {
95 $text = $this->data->text()->markdown();
96
97 return $this->payloads->getDescription(
98 $f,
99 'The zip archive with the files of the container.',
100 [
101 'skipped' => $f->list(
102 $text->simpleDocument('Files that could not be packed, empty if the archive is complete.'),
103 $f->object(
104 $text->simpleDocument('A file left out of the archive.'),
105 [
106 'ref_id' => $f->int($text->simpleDocument('Reference id of the file.')),
107 'title' => $f->string($text->simpleDocument('Title of the file.')),
108 'reason' => $f->string($text->simpleDocument('Why the file was left out.')),
109 ]
110 )
111 ),
112 ]
113 );
114 }
115
116 public function isAllowedToPerform(int $usr_id, mixed $parameters): bool
117 {
118 // the listing decides who may look into the container
119 return $this->list_files->isAllowedToPerform($usr_id, $parameters);
120 }
121
125 public function perform(mixed $parameters): ContainerArchive
126 {
127 $files = $this->list_files->perform($parameters);
128
129 if (count($files) > self::MAX_FILES) {
130 throw new \DomainException(
131 'This container holds more than ' . self::MAX_FILES . ' files, refusing to pack it.',
132 422
133 );
134 }
135
136 $size = array_sum(array_map(static fn(FileEntry $file): int => $file->size, $files));
137
138 if ($size > self::MAX_SIZE) {
139 throw new \DomainException(
140 'The files of this container are larger than ' . self::MAX_SIZE . ' bytes, refusing to pack them.',
141 422
142 );
143 }
144
145 $name = 'container_' . $parameters['parent_ref_id'] . '.zip';
146
147 // ZipArchive works on a path, so this is built outside of the ILIAS
148 // filesystems and read back in one go
149 $path = tempnam(sys_get_temp_dir(), 'ilias_zip_');
150
151 if ($path === false) {
152 throw new \RuntimeException('The archive could not be created.');
153 }
154
155 try {
156 $skipped = $this->pack($path, $files);
157
159 $this->payloads->fromString($name, 'application/zip', (string) file_get_contents($path)),
160 $skipped
161 );
162 } finally {
163 @unlink($path);
164 }
165 }
166
170 public function maybePerformAs(
171 InputFactory $input_factory,
172 int $usr_id,
173 array $raw_parameters
174 ): Result {
175 try {
176 $parameters = $this->toParameters($raw_parameters);
177
178 if (!$this->isAllowedToPerform($usr_id, $parameters)) {
179 return $this->data->error(
180 new \DomainException('You are not allowed to look into this container.', 403)
181 );
182 }
183
184 return $this->data->ok($this->perform([...$parameters, 'usr_id' => $usr_id]));
185 } catch (\Throwable $e) {
186 return $this->data->error(Failure::of($e));
187 }
188 }
189
195 private function pack(string $path, array $files): array
196 {
197 $zip = new \ZipArchive();
198
199 if ($zip->open($path, \ZipArchive::OVERWRITE) !== true) {
200 throw new \RuntimeException('The archive could not be created.');
201 }
202
203 $names = [];
204 $skipped = [];
205 $added = 0;
206
207 foreach ($files as $file) {
208 $name = null;
209 $content = false;
210
211 try {
212 // the listing has already dropped everything the user may not read,
213 // so the content may be taken through the internal door
214 $payload = $this->deliver_file->perform(['ref_id' => $file->ref_id]);
215 $name = $this->entryName($payload->filename);
216 $content = base64_decode($payload->content, true);
217 } catch (\Throwable) {
218 // left out below, the reason stays inside
219 }
220
221 // a file that cannot be read, decoded or added is left out and named,
222 // it must not end up in the archive as an empty one
223 if ($name === null
224 || $content === false
225 || !$zip->addFromString($this->uniqueName($names, $name), $content)
226 ) {
227 $skipped[] = new SkippedFile(
228 $file->ref_id,
229 $file->title,
230 'The content of this file could not be read.'
231 );
232 continue;
233 }
234
235 $added++;
236 }
237
238 // the entries are only written now, so this is where an archive breaks
239 if (!$zip->close()) {
240 throw new \RuntimeException('The archive could not be created.');
241 }
242
243 // ZipArchive writes nothing for an archive without entries, which would
244 // hand out an empty file; an empty zip is its end of central directory
245 if ($added === 0) {
246 file_put_contents($path, "PK\x05\x06" . str_repeat("\0", 18));
247 }
248
249 return $skipped;
250 }
251
258 private function entryName(string $filename): string
259 {
260 $parts = explode('/', str_replace('\\', '/', $filename));
261 $name = ltrim((string) end($parts), '.');
262
263 return trim($name) === '' ? 'file' : $name;
264 }
265
269 private function uniqueName(array &$names, string $filename): string
270 {
271 if (!isset($names[$filename])) {
272 $names[$filename] = 1;
273
274 return $filename;
275 }
276
277 $extension = pathinfo($filename, PATHINFO_EXTENSION);
278 $base = pathinfo($filename, PATHINFO_FILENAME);
279
280 // a file may already carry the name a duplicate would get
281 do {
282 $name = $base . ' (' . ++$names[$filename] . ')' . ($extension === '' ? '' : '.' . $extension);
283 } while (isset($names[$name]));
284
285 $names[$name] = 1;
286
287 return $name;
288 }
289
295 private function toParameters(array $raw_parameters): array
296 {
297 $parent_ref_id = $this->parameter->id($raw_parameters, 'parent_ref_id', 'A container is required.');
298
299 return [
300 'parent_ref_id' => $parent_ref_id,
301 'recursive' => $this->parameter->flag($raw_parameters, 'recursive', false),
302 ];
303 }
304}
$filename
Definition: buildRTE.php:78
Basic class for Queries.
Definition: Query.php:27
This describes some datastructure in terms of standard data structures such as primitives,...
Definition: Description.php:33
Builds data types.
Definition: Factory.php:36
Builds the payload of a delivering activity and describes its shape.
What the file activities hand to Data\Factory::error() when something failed: the result only takes e...
Definition: Failure.php:39
static of(\Throwable $e)
Definition: Failure.php:40
Reads the raw parameters of an activity strictly, with the Refinery.
Definition: Parameter.php:42
The zip archive of a container: a file payload like every other delivery, so a consumer writes it the...
static of(FilePayload $payload, array $skipped)
Deliver the content of a file object of the repository.
Deliver the files of a container as one zip archive.
isAllowedToPerform(int $usr_id, mixed $parameters)
This shall check if the given user is allowed to perform the activity based on business rules of this...
__construct(private readonly ListFiles $list_files, private readonly DeliverFileObject $deliver_file, private readonly FilePayloadFactory $payloads, private readonly Parameter $parameter, private readonly DataFactory $data=new DataFactory(),)
const int MAX_SIZE
The archive is built and handed out in memory, encoded on top of that, so the sum of what goes into i...
entryName(string $filename)
A name for an entry that stays in the folder it is unpacked into: the storage names its resources aft...
maybePerformAs(InputFactory $input_factory, int $usr_id, array $raw_parameters)
One file of a listing.
Definition: FileEntry.php:30
List the files in a container of the repository.
Definition: ListFiles.php:47
A file a packing activity has left out, and why - so the user learns that the archive is not complete...
Definition: SkippedFile.php:28
try
A result encapsulates a value or an error and simplifies the handling of those.
Definition: Result.php:29
This describes inputs that can be used in forms.
Definition: FormInput.php:33
This is how a factory for inputs looks like.
Definition: Factory.php:27
This is what a factory for input fields looks like.
Definition: Factory.php:31
$data
Definition: storeScorm.php:45
$text
Definition: xapiexit.php:21