Completed
Pull Request — master (#488)
by Helpful
1295:51 queued 1292:33
created

DNDataArchive::generateFilepath()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12
Code Lines 9

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 12
rs 9.4285
cc 1
eloc 9
nc 1
nop 1
1
<?php
2
use \Symfony\Component\Process\Process;
3
4
/**
5
 * Represents a file archive of database and/or assets extracted from
6
 * a specific Deploynaut environment.
7
 *
8
 * The model can also represent a request to upload a file later,
9
 * through offline processes like mailing a DVD. In order to associate
10
 * and authenticate those requests easily, an upload token is generated for every archive.
11
 *
12
 * The "OriginalEnvironment" points to original source of this snapshot
13
 * (the one it was backed up from). It will be empty if the snapshot has been created with offline process.
14
 *
15
 * The "Environment" denotes the ownership of the snapshot. It will be initially set to match the
16
 * "OriginalEnvironment", but can be changed later. During the offline process the ownership can be set up
17
 * arbitrarily.
18
 *
19
 * When moving snapshots, the file always remains in its initial location.
20
 *
21
 * The archive can have associations to {@link DNDataTransfer}:
22
 * - Zero transfers if a manual upload was requested, but not fulfilled yet
23
 * - One transfer with Direction=get for a backup from an environment
24
 * - One or more transfers with Direction=push for a restore to an environment
25
 *
26
 * The "Author" is either the person creating the archive through a "backup" operation,
27
 * the person uploading through a web form, or the person requesting a manual upload.
28
 *
29
 * The "Mode" is what the "Author" said the file includes (either 'only assets', 'only
30
 * database', or both). This is used in the ArchiveList.ss template.
31
 *
32
 * @property Varchar $UploadToken
33
 * @property Varchar $ArchiveFileHash
34
 * @property Enum $Mode
35
 * @property Boolean $IsBackup
36
 * @property Boolean $IsManualUpload
37
 *
38
 * @method Member Author()
39
 * @property int $AuthorID
40
 * @method DNEnvironment OriginalEnvironment()
41
 * @property int $OriginalEnvironmentID
42
 * @method DNEnvironment Environment()
43
 * @property int $EnvironmentID
44
 * @method File ArchiveFile()
45
 * @property int $ArchiveFileID
46
 *
47
 * @method ManyManyList DataTransfers()
48
 *
49
 */
50
class DNDataArchive extends DataObject {
51
52
	private static $db = array(
53
		'UploadToken' => 'Varchar(8)',
54
		'ArchiveFileHash' => 'Varchar(32)',
55
		"Mode" => "Enum('all, assets, db', '')",
56
		"IsBackup" => "Boolean",
57
		"IsManualUpload" => "Boolean",
58
	);
59
60
	private static $has_one = array(
61
		'Author' => 'Member',
62
		'OriginalEnvironment' => 'DNEnvironment',
63
		'Environment' => 'DNEnvironment',
64
		'ArchiveFile' => 'File'
65
	);
66
67
	private static $has_many = array(
68
		'DataTransfers' => 'DNDataTransfer',
69
	);
70
71
	private static $singular_name = 'Data Archive';
72
73
	private static $plural_name = 'Data Archives';
74
75
	private static $summary_fields = array(
76
		'Created' => 'Created',
77
		'Author.Title' => 'Author',
78
		'Environment.Project.Name' => 'Project',
79
		'OriginalEnvironment.Name' => 'Origin',
80
		'Environment.Name' => 'Environment',
81
		'ArchiveFile.Name' => 'File',
82
	);
83
84
	private static $searchable_fields = array(
85
		'Environment.Project.Name' => array(
86
			'title' => 'Project',
87
		),
88
		'OriginalEnvironment.Name' => array(
89
			'title' => 'Origin',
90
		),
91
		'Environment.Name' => array(
92
			'title' => 'Environment',
93
		),
94
		'UploadToken' => array(
95
			'title' => 'Upload Token',
96
		),
97
		'Mode' => array(
98
			'title' => 'Mode',
99
		),
100
	);
101
102
	private static $_cache_can_restore = array();
103
104
	private static $_cache_can_download = array();
105
106
	public static function get_mode_map() {
107
		return array(
108
			'all' => 'Database and Assets',
109
			'db' => 'Database only',
110
			'assets' => 'Assets only',
111
		);
112
	}
113
114
	/**
115
	 * Returns a unique token to correlate an offline item (posted DVD)
116
	 * with a specific archive placeholder.
117
	 *
118
	 * @return string
119
	 */
120
	public static function generate_upload_token($chars = 8) {
121
		$generator = new RandomGenerator();
122
		return strtoupper(substr($generator->randomToken(), 0, $chars));
123
	}
124
125
	public function onBeforeWrite() {
126
		if(!$this->AuthorID) {
127
			$this->AuthorID = Member::currentUserID();
128
		}
129
130
		parent::onBeforeWrite();
131
	}
132
133
	public function onAfterDelete() {
134
		$this->ArchiveFile()->delete();
135
	}
136
137
	public function getCMSFields() {
138
		$fields = parent::getCMSFields();
139
		$fields->removeByName('OriginalEnvironmentID');
140
		$fields->removeByName('EnvironmentID');
141
		$fields->removeByName('ArchiveFile');
142
		$fields->addFieldsToTab(
143
			'Root.Main',
144
			array(
145
				new ReadonlyField('ProjectName', 'Project', $this->Environment()->Project()->Name),
146
				new ReadonlyField('OriginalEnvironmentName', 'OriginalEnvironment', $this->OriginalEnvironment()->Name),
147
				new ReadonlyField('EnvironmentName', 'Environment', $this->Environment()->Name),
148
				$linkField = new ReadonlyField(
149
					'DataArchive',
150
					'Archive File',
151
					sprintf(
152
						'<a href="%s">%s</a>',
153
						$this->ArchiveFile()->AbsoluteURL,
154
						$this->ArchiveFile()->Filename
155
					)
156
				),
157
				new GridField(
158
					'DataTransfers',
159
					'Transfers',
160
					$this->DataTransfers()
161
				),
162
			)
163
		);
164
		$linkField->dontEscape = true;
165
		$fields = $fields->makeReadonly();
166
167
		return $fields;
168
	}
169
170
	public function getDefaultSearchContext() {
171
		$context = parent::getDefaultSearchContext();
172
		$context->getFields()->dataFieldByName('Mode')->setHasEmptyDefault(true);
173
174
		return $context;
175
	}
176
177
	/**
178
	 * Calculates and returns a human-readable size of this archive file. If the file exists, it will determine
179
	 * whether to display the output in bytes, kilobytes, megabytes, or gigabytes.
180
	 *
181
	 * @return string The human-readable size of this archive file
182
	 */
183
	public function FileSize() {
184
		if($this->ArchiveFile()->exists()) {
185
			return $this->ArchiveFile()->getSize();
186
		} else {
187
			return "N/A";
188
		}
189
	}
190
191
	public function getModeNice() {
192
		if($this->Mode == 'all') {
193
			return 'database and assets';
194
		} else {
195
			return $this->Mode;
196
		}
197
	}
198
199
	/**
200
	 * Some archives don't have files attached to them yet,
201
	 * because a file has been posted offline and is waiting to be uploaded
202
	 * against this "archive placeholder".
203
	 *
204
	 * @return boolean
205
	 */
206
	public function isPending() {
207
		return !($this->ArchiveFileID);
208
	}
209
210
	/**
211
	 * Inferred from both restore and backup permissions.
212
	 *
213
	 * @param Member|null $member The {@link Member} object to test against.
214
	 */
215
	public function canView($member = null) {
216
		return ($this->canRestore($member) || $this->canDownload($member));
217
	}
218
219
	/**
220
	 * Whether a {@link Member} can restore this archive to an environment.
221
	 * This only needs to be checked *once* per member and environment.
222
	 *
223
	 * @param Member|null $member The {@link Member} object to test against.
224
	 * @return true if $member (or the currently logged in member if null) can upload this archive
225
	 */
226 View Code Duplication
	public function canRestore($member = null) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
227
		$memberID = $member ? $member->ID : Member::currentUserID();
228
		if(!$memberID) {
229
			return false;
230
		}
231
232
		$key = $memberID . '-' . $this->EnvironmentID;
233
		if(!isset(self::$_cache_can_restore[$key])) {
234
			self::$_cache_can_restore[$key] = $this->Environment()->canUploadArchive($member);
235
		}
236
237
		return self::$_cache_can_restore[$key];
238
	}
239
240
	/**
241
	 * Whether a {@link Member} can download this archive to their PC.
242
	 * This only needs to be checked *once* per member and environment.
243
	 *
244
	 * @param Member|null $member The {@link Member} object to test against.
245
	 * @return true if $member (or the currently logged in member if null) can download this archive
246
	 */
247 View Code Duplication
	public function canDownload($member = null) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
248
		$memberID = $member ? $member->ID : Member::currentUserID();
249
		if(!$memberID) {
250
			return false;
251
		}
252
253
		$key = $memberID . '-' . $this->EnvironmentID;
254
		if(!isset(self::$_cache_can_download[$key])) {
255
			self::$_cache_can_download[$key] = $this->Environment()->canDownloadArchive($member);
256
		}
257
		return self::$_cache_can_download[$key];
258
	}
259
260
	/**
261
	 * Whether a {@link Member} can delete this archive from staging area.
262
	 *
263
	 * @param Member|null $member The {@link Member} object to test against.
264
	 * @return boolean if $member (or the currently logged in member if null) can delete this archive
265
	 */
266
	public function canDelete($member = null) {
267
		return $this->Environment()->canDeleteArchive($member);
268
	}
269
270
	/**
271
	 * Check if this member can move archive into the environment.
272
	 *
273
	 * @param DNEnvironment $targetEnv Environment to check.
274
	 * @param Member|null $member The {@link Member} object to test against. If null, uses Member::currentMember();
275
	 *
276
	 * @return boolean true if $member can upload archives linked to this environment, false if they can't.
277
	 */
278
	public function canMoveTo($targetEnv, $member = null) {
279
		if($this->Environment()->Project()->ID != $targetEnv->Project()->ID) {
280
			// We don't permit moving snapshots between projects at this stage.
281
			return false;
282
		}
283
284
		if(!$member) {
285
			$member = Member::currentUser();
286
		}
287
288
		// Must be logged in to check permissions
289
		if(!$member) {
290
			return false;
291
		}
292
293
		// Admin can always move.
294
		if(Permission::checkMember($member, 'ADMIN')) {
295
			return true;
296
		}
297
298
		// Checks if the user can actually access the archive.
299
		if(!$this->canDownload($member)) {
300
			return false;
301
		}
302
303
		// Hooks into ArchiveUploaders permission to prevent proliferation of permission checkboxes.
304
		// Bypasses the quota check - we don't need to check for it as long as we move the snapshot within the project.
305
		return $targetEnv->ArchiveUploaders()->byID($member->ID)
306
			|| $member->inGroups($targetEnv->ArchiveUploaderGroups());
307
	}
308
309
	/**
310
	 * Finds all environments within this project where the archive can be moved to.
311
	 * Excludes current environment automatically.
312
	 *
313
	 * @return ArrayList List of valid environments.
314
	 */
315
	public function validTargetEnvironments() {
316
		$archive = $this;
317
		$envs = $this->Environment()->Project()->DNEnvironmentList()
318
			->filterByCallback(function($item) use ($archive) {
319
				return $archive->EnvironmentID != $item->ID && $archive->canMoveTo($item);
320
			});
321
322
		return $envs;
323
	}
324
325
	/**
326
	 * Returns a unique filename, including project/environment/timestamp details.
327
	 * @return string
328
	 */
329
	public function generateFilename(DNDataTransfer $dataTransfer) {
330
		$generator = new RandomGenerator();
331
		$filter = FileNameFilter::create();
332
333
		return sprintf(
334
			'%s-%s-%s-%s-%s',
335
			$filter->filter(strtolower($this->OriginalEnvironment()->Project()->Name)),
336
			$filter->filter(strtolower($this->OriginalEnvironment()->Name)),
337
			$dataTransfer->Mode,
338
			date('Ymd'),
339
			sha1($generator->generateEntropy())
340
		);
341
	}
342
343
	/**
344
	 * Returns a path unique to a specific transfer, including project/environment details.
345
	 * Does not create the path on the filesystem. Can be used to store files related to this transfer.
346
	 *
347
	 * @param DNDataTransfer
348
	 * @return string Absolute file path
349
	 */
350
	public function generateFilepath(DNDataTransfer $dataTransfer) {
351
		$data = DNData::inst();
352
		$transferDir = $data->getDataTransferDir();
353
		$filter = FileNameFilter::create();
354
355
		return sprintf('%s/%s/%s/transfer-%s/',
356
			$transferDir,
357
			$filter->filter(strtolower($this->OriginalEnvironment()->Project()->Name)),
358
			$filter->filter(strtolower($this->OriginalEnvironment()->Name)),
359
			$dataTransfer->ID
360
		);
361
	}
362
363
	/**
364
	 * Attach an sspak file path to this archive and associate the transfer.
365
	 * Does the job of creating a {@link File} record, and setting correct paths into the assets directory.
366
	 *
367
	 * @param string $sspakFilepath
368
	 * @param DNDataTransfer $dataTransfer
369
	 * @return bool
370
	 */
371
	public function attachFile($sspakFilepath, DNDataTransfer $dataTransfer) {
372
		$sspakFilepath = ltrim(
373
			str_replace(
374
				array(ASSETS_PATH, realpath(ASSETS_PATH)),
375
				'',
376
				$sspakFilepath
377
			),
378
			DIRECTORY_SEPARATOR
379
		);
380
381
		$folder = Folder::find_or_make(dirname($sspakFilepath));
382
		$file = new File();
383
		$file->Name = basename($sspakFilepath);
384
		$file->Filename = $sspakFilepath;
385
		$file->ParentID = $folder->ID;
386
		$file->write();
387
388
		// "Status" will be updated by the job execution
389
		$dataTransfer->write();
390
391
		// Get file hash to ensure consistency.
392
		// Only do this when first associating the file since hashing large files is expensive.
393
		// Note that with CapistranoDeploymentBackend the file won't be available yet, as it
394
		// gets put in place immediately after this method gets called. In which case, it will
395
		// be hashed in setArchiveFromFiles()
396
		if(file_exists($file->FullPath)) {
397
			$this->ArchiveFileHash = md5_file($file->FullPath);
398
		}
399
		$this->ArchiveFileID = $file->ID;
400
		$this->DataTransfers()->add($dataTransfer);
401
		$this->write();
402
403
		return true;
404
	}
405
406
	/**
407
	 * Extract the current sspak contents into the given working directory.
408
	 * This also extracts the assets and database and puts them into
409
	 * <workingdir>/database.sql and <workingdir>/assets, respectively.
410
	 *
411
	 * @param string|null $workingDir The path to extract to
412
	 * @throws RuntimeException
413
	 * @return bool
414
	 */
415
	public function extractArchive($workingDir = null) {
416
		if(!is_dir($workingDir)) {
417
			mkdir($workingDir, 0700, true);
418
		}
419
420
		$cleanupFn = function() use($workingDir) {
421
			$process = new Process(sprintf('rm -rf %s', escapeshellarg($workingDir)));
422
			$process->run();
423
		};
424
425
		// Extract *.sspak to a temporary location
426
		$sspakFilename = $this->ArchiveFile()->FullPath;
427
		$process = new Process(sprintf(
428
			'sspak extract %s %s',
429
			escapeshellarg($sspakFilename),
430
			escapeshellarg($workingDir)
431
		));
432
		$process->setTimeout(3600);
433
		$process->run();
434
		if(!$process->isSuccessful()) {
435
			$cleanupFn();
436
			throw new RuntimeException(sprintf('Could not extract the sspak file: %s', $process->getErrorOutput()));
437
		}
438
439
		// Extract database.sql.gz to <workingdir>/database.sql
440 View Code Duplication
		if(file_exists($workingDir . DIRECTORY_SEPARATOR . 'database.sql.gz')) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
441
			$process = new Process('gunzip database.sql.gz', $workingDir);
442
			$process->setTimeout(3600);
443
			$process->run();
444
			if(!$process->isSuccessful()) {
445
				$cleanupFn();
446
				throw new RuntimeException(sprintf('Could not extract the db archive: %s', $process->getErrorOutput()));
447
			}
448
		}
449
450
		// Extract assets.tar.gz to <workingdir>/assets/
451 View Code Duplication
		if(file_exists($workingDir . DIRECTORY_SEPARATOR . 'assets.tar.gz')) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
452
			$process = new Process('tar xzf assets.tar.gz', $workingDir);
453
			$process->setTimeout(3600);
454
			$process->run();
455
			if(!$process->isSuccessful()) {
456
				$cleanupFn();
457
				throw new RuntimeException(sprintf('Could not extract the assets archive: %s', $process->getErrorOutput()));
458
			}
459
		}
460
461
		return true;
462
	}
463
464
	/**
465
	 * Validate that an sspak contains the correct content.
466
	 *
467
	 * For example, if the user uploaded an sspak containing just the db, but declared in the form
468
	 * that it contained db+assets, then the archive is not valid.
469
	 *
470
	 * @param string|null $mode "db", "assets", or "all". This is the content we're checking for. Default to the archive setting
0 ignored issues
show
Coding Style introduced by
This line exceeds maximum limit of 120 characters; contains 125 characters

Overly long lines are hard to read on any screen. Most code styles therefor impose a maximum limit on the number of characters in a line.

Loading history...
471
	 * @return ValidationResult
472
	 */
473
	public function validateArchiveContents($mode = null) {
474
		$mode = $mode ?: $this->Mode;
475
		$result = new ValidationResult();
476
477
		$file = $this->ArchiveFile()->FullPath;
478
479
		if(!is_readable($file)) {
480
			$result->error(sprintf('SSPak file "%s" cannot be read.', $file));
481
			return $result;
482
		}
483
484
		$process = new Process(sprintf('tar -tf %s', escapeshellarg($file)));
485
		$process->setTimeout(120);
486
		$process->run();
487
		if(!$process->isSuccessful()) {
488
			throw new RuntimeException(sprintf('Could not list files in archive: %s', $process->getErrorOutput()));
489
		}
490
491
		$output = explode(PHP_EOL, $process->getOutput());
492
		$files = array_filter($output);
493
494
		if(in_array($mode, array('all', 'db')) && !in_array('database.sql.gz', $files)) {
495
			$result->error('The snapshot is missing the database.');
496
			return $result;
497
		}
498
499
		if(in_array($mode, array('all', 'assets')) && !in_array('assets.tar.gz', $files)) {
500
			$result->error('The snapshot is missing assets.');
501
			return $result;
502
		}
503
504
		return $result;
505
	}
506
507
	/**
508
	 * Given a path that already exists and contains an extracted sspak, including
509
	 * the assets, fix all of the file permissions so they're in a state ready to
510
	 * be pushed to remote servers.
511
	 *
512
	 * Normally, command line tar will use permissions found in the archive, but will
513
	 * substract the user's umask from them. This has a potential to create unreadable
514
	 * files, e.g. cygwin on Windows will pack files with mode 000, hence why this fix
515
	 * is necessary.
516
	 *
517
	 * @param string|null $workingDir The path of where the sspak has been extracted to
518
	 * @throws RuntimeException
519
	 * @return bool
520
	 */
521
	public function fixArchivePermissions($workingDir) {
522
		$fixCmds = array(
523
			// The directories need to have permissions changed one by one (hence the ; instead of +),
524
			// otherwise we might end up having no +x access to a directory deeper down.
525
			sprintf('find %s -type d -exec chmod 755 {} \;', escapeshellarg($workingDir)),
526
			sprintf('find %s -type f -exec chmod 644 {} +', escapeshellarg($workingDir))
527
		);
528
529
		foreach($fixCmds as $cmd) {
530
			$process = new Process($cmd);
531
			$process->setTimeout(3600);
532
			$process->run();
533
			if(!$process->isSuccessful()) {
534
				throw new RuntimeException($process->getErrorOutput());
535
			}
536
		}
537
538
		return true;
539
	}
540
541
	/**
542
	 * Given extracted sspak contents, create an sspak from it
543
	 * and overwrite the current ArchiveFile with it's contents.
544
	 *
545
	 * @param string|null $workingDir The path of where the sspak has been extracted to
546
	 * @return bool
547
	 */
548
	public function setArchiveFromFiles($workingDir) {
549
		$command = sprintf('sspak saveexisting %s 2>&1', $this->ArchiveFile()->FullPath);
550
		if($this->Mode == 'db') {
551
			$command .= sprintf(' --db=%s/database.sql', $workingDir);
552
		} elseif($this->Mode == 'assets') {
553
			$command .= sprintf(' --assets=%s/assets', $workingDir);
554
		} else {
555
			$command .= sprintf(' --db=%s/database.sql --assets=%s/assets', $workingDir, $workingDir);
556
		}
557
558
		$process = new Process($command, $workingDir);
559
		$process->setTimeout(3600);
560
		$process->run();
561
		if(!$process->isSuccessful()) {
562
			throw new RuntimeException($process->getErrorOutput());
563
		}
564
565
		$this->ArchiveFileHash = md5_file($this->ArchiveFile()->FullPath);
566
		$this->write();
567
568
		return true;
569
	}
570
571
}
572