Page MenuHomePhorge

No OneTemporary

diff --git a/src/applications/audit/conduit/ConduitAPI_audit_query_Method.php b/src/applications/audit/conduit/ConduitAPI_audit_query_Method.php
index af66e7b828..8db30d54b4 100644
--- a/src/applications/audit/conduit/ConduitAPI_audit_query_Method.php
+++ b/src/applications/audit/conduit/ConduitAPI_audit_query_Method.php
@@ -1,76 +1,88 @@
<?php
final class ConduitAPI_audit_query_Method extends ConduitAPI_audit_Method {
public function getMethodDescription() {
return 'Query audit requests.';
}
public function defineParamTypes() {
$statuses = array(
'status-any',
'status-open',
);
$status_const = $this->formatStringConstants($statuses);
return array(
'auditorPHIDs' => 'optional list<phid>',
'commitPHIDs' => 'optional list<phid>',
'status' => 'optional '.$status_const.' (default = "status-any")',
'offset' => 'optional int',
'limit' => 'optional int (default = 100)',
);
}
public function defineReturnType() {
return 'list<dict>';
}
public function defineErrorTypes() {
return array(
);
}
protected function execute(ConduitAPIRequest $request) {
$query = id(new DiffusionCommitQuery())
->setViewer($request->getUser());
$auditor_phids = $request->getValue('auditorPHIDs', array());
if ($auditor_phids) {
$query->withAuditorPHIDs($auditor_phids);
}
$commit_phids = $request->getValue('commitPHIDs', array());
if ($commit_phids) {
$query->withPHIDs($commit_phids);
}
$status = $request->getValue(
'status',
DiffusionCommitQuery::AUDIT_STATUS_ANY);
$query->withAuditStatus($status);
+ // NOTE: These affect the number of commits identified, which is sort of
+ // reasonable but means the method may return an arbitrary number of
+ // actual audit requests.
$query->setOffset($request->getValue('offset', 0));
$query->setLimit($request->getValue('limit', 100));
$commits = $query->execute();
+ $auditor_map = array_fuse($auditor_phids);
+
$results = array();
foreach ($commits as $commit) {
$requests = $commit->getAudits();
foreach ($requests as $request) {
+
+ // If this audit isn't triggered for one of the requested PHIDs,
+ // skip it.
+ if ($auditor_map && empty($auditor_map[$request->getAuditorPHID()])) {
+ continue;
+ }
+
$results[] = array(
'id' => $request->getID(),
'commitPHID' => $request->getCommitPHID(),
'auditorPHID' => $request->getAuditorPHID(),
'reasons' => $request->getAuditReasons(),
'status' => $request->getAuditStatus(),
);
}
}
return $results;
}
}
diff --git a/src/applications/audit/management/PhabricatorAuditManagementDeleteWorkflow.php b/src/applications/audit/management/PhabricatorAuditManagementDeleteWorkflow.php
index af951c754f..9700c38b26 100644
--- a/src/applications/audit/management/PhabricatorAuditManagementDeleteWorkflow.php
+++ b/src/applications/audit/management/PhabricatorAuditManagementDeleteWorkflow.php
@@ -1,269 +1,283 @@
<?php
final class PhabricatorAuditManagementDeleteWorkflow
extends PhabricatorAuditManagementWorkflow {
public function didConstruct() {
$this
->setName('delete')
->setExamples('**delete** [--dry-run] ...')
->setSynopsis('Delete audit requests matching parameters.')
->setArguments(
array(
array(
'name' => 'dry-run',
'help' => 'Show what would be deleted, but do not actually delete '.
'anything.',
),
array(
'name' => 'users',
'param' => 'names',
'help' => 'Select only audits by a given list of users.',
),
array(
'name' => 'repositories',
'param' => 'repos',
'help' => 'Select only audits in a given list of repositories.',
),
array(
'name' => 'commits',
'param' => 'commits',
'help' => 'Select only audits for the given commits.',
),
array(
'name' => 'min-commit-date',
'param' => 'date',
'help' => 'Select only audits for commits on or after the given '.
'date.',
),
array(
'name' => 'max-commit-date',
'param' => 'date',
'help' => 'Select only audits for commits on or before the given '.
'date.',
),
array(
'name' => 'status',
'param' => 'status',
'help' => 'Select only audits in the given status. By default, '.
'only open audits are selected.',
),
array(
'name' => 'ids',
'param' => 'ids',
'help' => 'Select only audits with the given IDs.',
),
));
}
public function execute(PhutilArgumentParser $args) {
$viewer = $this->getViewer();
$users = $this->loadUsers($args->getArg('users'));
$repos = $this->loadRepos($args->getArg('repositories'));
$commits = $this->loadCommits($args->getArg('commits'));
$ids = $this->parseList($args->getArg('ids'));
$status = $args->getArg('status');
if (!$status) {
$status = DiffusionCommitQuery::AUDIT_STATUS_OPEN;
}
$min_date = $this->loadDate($args->getArg('min-commit-date'));
$max_date = $this->loadDate($args->getArg('max-commit-date'));
if ($min_date && $max_date && ($min_date > $max_date)) {
throw new PhutilArgumentUsageException(
'Specified max date must come after specified min date.');
}
$is_dry_run = $args->getArg('dry-run');
$query = id(new DiffusionCommitQuery())
->setViewer($this->getViewer())
->needAuditRequests(true);
if ($status) {
$query->withAuditStatus($status);
}
+ $id_map = array();
if ($ids) {
+ $id_map = array_fuse($ids);
$query->withAuditIDs($ids);
}
if ($repos) {
$query->withRepositoryIDs(mpull($repos, 'getID'));
}
+ $auditor_map = array();
if ($users) {
- $query->withAuditorPHIDs(mpull($users, 'getPHID'));
+ $auditor_map = array_fuse(mpull($users, 'getPHID'));
+ $query->withAuditorPHIDs($auditor_map);
}
if ($commits) {
$query->withPHIDs(mpull($commits, 'getPHID'));
}
$commits = $query->execute();
$commits = mpull($commits, null, 'getPHID');
$audits = array();
foreach ($commits as $commit) {
- $curr_audits = $commit->getAudits();
- foreach ($audits as $key => $audit) {
+ $commit_audits = $commit->getAudits();
+ foreach ($commit_audits as $key => $audit) {
+ if ($id_map && empty($id_map[$audit->getID()])) {
+ unset($commit_audits[$key]);
+ continue;
+ }
+
+ if ($auditor_map && empty($auditor_map[$audit->getAuditorPHID()])) {
+ unset($commit_audits[$key]);
+ continue;
+ }
+
if ($min_date && $commit->getEpoch() < $min_date) {
- unset($audits[$key]);
+ unset($commit_audits[$key]);
continue;
}
if ($max_date && $commit->getEpoch() > $max_date) {
- unset($audits[$key]);
+ unset($commit_audits[$key]);
continue;
}
}
- $audits[] = $curr_audits;
+ $audits[] = $commit_audits;
}
$audits = array_mergev($audits);
$console = PhutilConsole::getConsole();
if (!$audits) {
$console->writeErr("%s\n", pht('No audits match the query.'));
return 0;
}
$handles = id(new PhabricatorHandleQuery())
->setViewer($this->getViewer())
->withPHIDs(mpull($audits, 'getAuditorPHID'))
->execute();
foreach ($audits as $audit) {
$commit = $commits[$audit->getCommitPHID()];
$console->writeOut(
"%s\n",
sprintf(
'%10d %-16s %-16s %s: %s',
$audit->getID(),
$handles[$audit->getAuditorPHID()]->getName(),
PhabricatorAuditStatusConstants::getStatusName(
$audit->getAuditStatus()),
$commit->getRepository()->formatCommitName(
$commit->getCommitIdentifier()),
trim($commit->getSummary())));
}
if (!$is_dry_run) {
$message = pht(
'Really delete these %d audit(s)? They will be permanently deleted '.
'and can not be recovered.',
count($audits));
if ($console->confirm($message)) {
foreach ($audits as $audit) {
$id = $audit->getID();
$console->writeOut("%s\n", pht('Deleting audit %d...', $id));
$audit->delete();
}
}
}
return 0;
}
private function loadUsers($users) {
$users = $this->parseList($users);
if (!$users) {
return null;
}
$objects = id(new PhabricatorPeopleQuery())
->setViewer($this->getViewer())
->withUsernames($users)
->execute();
$objects = mpull($objects, null, 'getUsername');
foreach ($users as $name) {
if (empty($objects[$name])) {
throw new PhutilArgumentUsageException(
pht('No such user with username "%s"!', $name));
}
}
return $objects;
}
private function parseList($list) {
$list = preg_split('/\s*,\s*/', $list);
foreach ($list as $key => $item) {
$list[$key] = trim($item);
}
foreach ($list as $key => $item) {
if (!strlen($item)) {
unset($list[$key]);
}
}
return $list;
}
private function loadRepos($callsigns) {
$callsigns = $this->parseList($callsigns);
if (!$callsigns) {
return null;
}
$repos = id(new PhabricatorRepositoryQuery())
->setViewer($this->getViewer())
->withCallsigns($callsigns)
->execute();
$repos = mpull($repos, null, 'getCallsign');
foreach ($callsigns as $sign) {
if (empty($repos[$sign])) {
throw new PhutilArgumentUsageException(
pht('No such repository with callsign "%s"!', $sign));
}
}
return $repos;
}
private function loadDate($date) {
if (!$date) {
return null;
}
$epoch = strtotime($date);
if (!$epoch || $epoch < 1) {
throw new PhutilArgumentUsageException(
pht(
'Unable to parse date "%s". Use a format like "2000-01-01".',
$date));
}
return $epoch;
}
private function loadCommits($commits) {
$names = $this->parseList($commits);
if (!$names) {
return null;
}
$query = id(new DiffusionCommitQuery())
->setViewer($this->getViewer())
->withIdentifiers($names);
$commits = $query->execute();
$map = $query->getIdentifierMap();
foreach ($names as $name) {
if (empty($map[$name])) {
throw new PhutilArgumentUsageException(
pht('No such commit "%s"!', $name));
}
}
return $commits;
}
}
diff --git a/src/applications/diffusion/query/DiffusionCommitQuery.php b/src/applications/diffusion/query/DiffusionCommitQuery.php
index 73a9221726..0e28479040 100644
--- a/src/applications/diffusion/query/DiffusionCommitQuery.php
+++ b/src/applications/diffusion/query/DiffusionCommitQuery.php
@@ -1,515 +1,514 @@
<?php
final class DiffusionCommitQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $ids;
private $phids;
private $authorPHIDs;
private $defaultRepository;
private $identifiers;
private $repositoryIDs;
private $identifierMap;
private $needAuditRequests;
private $auditIDs;
private $auditorPHIDs;
private $auditAwaitingUser;
private $auditStatus;
const AUDIT_STATUS_ANY = 'audit-status-any';
const AUDIT_STATUS_OPEN = 'audit-status-open';
const AUDIT_STATUS_CONCERN = 'audit-status-concern';
- private $loadAuditIds;
private $needCommitData;
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
public function withAuthorPHIDs(array $phids) {
$this->authorPHIDs = $phids;
return $this;
}
/**
* Load commits by partial or full identifiers, e.g. "rXab82393", "rX1234",
* or "a9caf12". When an identifier matches multiple commits, they will all
* be returned; callers should be prepared to deal with more results than
* they queried for.
*/
public function withIdentifiers(array $identifiers) {
$this->identifiers = $identifiers;
return $this;
}
/**
* Look up commits in a specific repository. This is a shorthand for calling
* @{method:withDefaultRepository} and @{method:withRepositoryIDs}.
*/
public function withRepository(PhabricatorRepository $repository) {
$this->withDefaultRepository($repository);
$this->withRepositoryIDs(array($repository->getID()));
return $this;
}
/**
* If a default repository is provided, ambiguous commit identifiers will
* be assumed to belong to the default repository.
*
* For example, "r123" appearing in a commit message in repository X is
* likely to be unambiguously "rX123". Normally the reference would be
* considered ambiguous, but if you provide a default repository it will
* be correctly resolved.
*/
public function withDefaultRepository(PhabricatorRepository $repository) {
$this->defaultRepository = $repository;
return $this;
}
public function withRepositoryIDs(array $repository_ids) {
$this->repositoryIDs = $repository_ids;
return $this;
}
public function needCommitData($need) {
$this->needCommitData = $need;
return $this;
}
public function needAuditRequests($need) {
$this->needAuditRequests = $need;
return $this;
}
/**
* Retuns true if we should join the audit table, either because we're
* interested in the information if it's available or because matching
* rows must always have it.
*/
private function shouldJoinAudits() {
- return
- $this->needAuditRequests ||
- $this->auditStatus ||
- $this->rowsMustHaveAudits();
+ return $this->auditStatus ||
+ $this->rowsMustHaveAudits();
}
/**
* Return true if we should `JOIN` (vs `LEFT JOIN`) the audit table, because
* matching commits will always have audit rows.
*/
private function rowsMustHaveAudits() {
return
$this->auditIDs ||
$this->auditorPHIDs ||
$this->auditAwaitingUser;
}
public function withAuditIDs(array $ids) {
$this->auditIDs = $ids;
return $this;
}
public function withAuditorPHIDs(array $auditor_phids) {
$this->auditorPHIDs = $auditor_phids;
return $this;
}
public function withAuditAwaitingUser(PhabricatorUser $user) {
$this->auditAwaitingUser = $user;
return $this;
}
public function withAuditStatus($status) {
$this->auditStatus = $status;
return $this;
}
public function getIdentifierMap() {
if ($this->identifierMap === null) {
throw new Exception(
'You must execute() the query before accessing the identifier map.');
}
return $this->identifierMap;
}
protected function getPagingColumn() {
return 'commit.id';
}
protected function willExecute() {
if ($this->identifierMap === null) {
$this->identifierMap = array();
}
}
protected function loadPage() {
$table = new PhabricatorRepositoryCommit();
$conn_r = $table->establishConnection('r');
$data = queryfx_all(
$conn_r,
- 'SELECT commit.* %Q FROM %T commit %Q %Q %Q %Q',
- $this->buildAuditSelect($conn_r),
+ 'SELECT commit.* FROM %T commit %Q %Q %Q %Q %Q',
$table->getTableName(),
$this->buildJoinClause($conn_r),
$this->buildWhereClause($conn_r),
+ $this->buildGroupClause($conn_r),
$this->buildOrderClause($conn_r),
$this->buildLimitClause($conn_r));
- if ($this->shouldJoinAudits()) {
- $this->loadAuditIds = ipull($data, 'audit_id');
- }
-
return $table->loadAllFromArray($data);
}
- private function buildAuditSelect($conn_r) {
- if ($this->shouldJoinAudits()) {
- return qsprintf(
- $conn_r,
- ', audit.id as audit_id');
- }
-
- return '';
- }
-
protected function willFilterPage(array $commits) {
$repository_ids = mpull($commits, 'getRepositoryID', 'getRepositoryID');
$repos = id(new PhabricatorRepositoryQuery())
->setViewer($this->getViewer())
->withIDs($repository_ids)
->execute();
foreach ($commits as $key => $commit) {
$repo = idx($repos, $commit->getRepositoryID());
if ($repo) {
$commit->attachRepository($repo);
} else {
unset($commits[$key]);
}
}
if ($this->identifiers !== null) {
$ids = array_fuse($this->identifiers);
$min_qualified = PhabricatorRepository::MINIMUM_QUALIFIED_HASH;
$result = array();
foreach ($commits as $commit) {
$prefix = 'r'.$commit->getRepository()->getCallsign();
$suffix = $commit->getCommitIdentifier();
if ($commit->getRepository()->isSVN()) {
if (isset($ids[$prefix.$suffix])) {
$result[$prefix.$suffix][] = $commit;
}
} else {
// This awkward contruction is so we can link the commits up in O(N)
// time instead of O(N^2).
for ($ii = $min_qualified; $ii <= strlen($suffix); $ii++) {
$part = substr($suffix, 0, $ii);
if (isset($ids[$prefix.$part])) {
$result[$prefix.$part][] = $commit;
}
if (isset($ids[$part])) {
$result[$part][] = $commit;
}
}
}
}
foreach ($result as $identifier => $matching_commits) {
if (count($matching_commits) == 1) {
$result[$identifier] = head($matching_commits);
} else {
// This reference is ambiguous (it matches more than one commit) so
- // don't link it
+ // don't link it.
unset($result[$identifier]);
}
}
$this->identifierMap += $result;
}
return $commits;
}
protected function didFilterPage(array $commits) {
if ($this->needCommitData) {
$data = id(new PhabricatorRepositoryCommitData())->loadAllWhere(
'commitID in (%Ld)',
mpull($commits, 'getID'));
$data = mpull($data, null, 'getCommitID');
foreach ($commits as $commit) {
$commit_data = idx($data, $commit->getID());
if (!$commit_data) {
$commit_data = new PhabricatorRepositoryCommitData();
}
$commit->attachCommitData($commit_data);
}
}
- if ($this->shouldJoinAudits()) {
- $load_ids = array_filter($this->loadAuditIds);
- if ($load_ids) {
- $requests = id(new PhabricatorRepositoryAuditRequest())
- ->loadAllWhere('id IN (%Ld)', $this->loadAuditIds);
- } else {
- $requests = array();
- }
+ // TODO: This should just be `needAuditRequests`, not `shouldJoinAudits()`,
+ // but leave that for a future diff.
+
+ if ($this->needAuditRequests || $this->shouldJoinAudits()) {
+ $requests = id(new PhabricatorRepositoryAuditRequest())->loadAllWhere(
+ 'commitPHID IN (%Ls)',
+ mpull($commits, 'getPHID'));
$requests = mgroup($requests, 'getCommitPHID');
foreach ($commits as $commit) {
$audit_requests = idx($requests, $commit->getPHID(), array());
$commit->attachAudits($audit_requests);
foreach ($audit_requests as $audit_request) {
$audit_request->attachCommit($commit);
}
}
}
return $commits;
}
private function buildWhereClause(AphrontDatabaseConnection $conn_r) {
$where = array();
if ($this->ids !== null) {
$where[] = qsprintf(
$conn_r,
'commit.id IN (%Ld)',
$this->ids);
}
if ($this->phids !== null) {
$where[] = qsprintf(
$conn_r,
'commit.phid IN (%Ls)',
$this->phids);
}
if ($this->repositoryIDs !== null) {
$where[] = qsprintf(
$conn_r,
'commit.repositoryID IN (%Ld)',
$this->repositoryIDs);
}
if ($this->authorPHIDs !== null) {
$where[] = qsprintf(
$conn_r,
'commit.authorPHID IN (%Ls)',
$this->authorPHIDs);
}
if ($this->identifiers !== null) {
$min_unqualified = PhabricatorRepository::MINIMUM_UNQUALIFIED_HASH;
$min_qualified = PhabricatorRepository::MINIMUM_QUALIFIED_HASH;
$refs = array();
$bare = array();
foreach ($this->identifiers as $identifier) {
$matches = null;
preg_match('/^(?:r([A-Z]+))?(.*)$/', $identifier, $matches);
$repo = nonempty($matches[1], null);
$identifier = nonempty($matches[2], null);
if ($repo === null) {
if ($this->defaultRepository) {
$repo = $this->defaultRepository->getCallsign();
}
}
if ($repo === null) {
if (strlen($identifier) < $min_unqualified) {
continue;
}
$bare[] = $identifier;
} else {
$refs[] = array(
'callsign' => $repo,
'identifier' => $identifier,
);
}
}
$sql = array();
foreach ($bare as $identifier) {
$sql[] = qsprintf(
$conn_r,
'(commit.commitIdentifier LIKE %> AND '.
'LENGTH(commit.commitIdentifier) = 40)',
$identifier);
}
if ($refs) {
$callsigns = ipull($refs, 'callsign');
$repos = id(new PhabricatorRepositoryQuery())
->setViewer($this->getViewer())
->withCallsigns($callsigns)
->execute();
$repos = mpull($repos, null, 'getCallsign');
foreach ($refs as $key => $ref) {
$repo = idx($repos, $ref['callsign']);
if (!$repo) {
continue;
}
if ($repo->isSVN()) {
if (!ctype_digit($ref['identifier'])) {
continue;
}
$sql[] = qsprintf(
$conn_r,
'(commit.repositoryID = %d AND commit.commitIdentifier = %s)',
$repo->getID(),
// NOTE: Because the 'commitIdentifier' column is a string, MySQL
// ignores the index if we hand it an integer. Hand it a string.
// See T3377.
(int)$ref['identifier']);
} else {
if (strlen($ref['identifier']) < $min_qualified) {
continue;
}
$sql[] = qsprintf(
$conn_r,
'(commit.repositoryID = %d AND commit.commitIdentifier LIKE %>)',
$repo->getID(),
$ref['identifier']);
}
}
}
if (!$sql) {
// If we discarded all possible identifiers (e.g., they all referenced
// bogus repositories or were all too short), make sure the query finds
// nothing.
throw new PhabricatorEmptyQueryException(
pht('No commit identifiers.'));
}
$where[] = '('.implode(' OR ', $sql).')';
}
if ($this->auditIDs !== null) {
$where[] = qsprintf(
$conn_r,
'audit.id IN (%Ld)',
$this->auditIDs);
}
if ($this->auditorPHIDs !== null) {
$where[] = qsprintf(
$conn_r,
'audit.auditorPHID IN (%Ls)',
$this->auditorPHIDs);
}
if ($this->auditAwaitingUser) {
$awaiting_user_phid = $this->auditAwaitingUser->getPHID();
// Exclude package and project audits associated with commits where
// the user is the author.
$where[] = qsprintf(
$conn_r,
'(commit.authorPHID IS NULL OR commit.authorPHID != %s)
OR (audit.auditorPHID = %s)',
$awaiting_user_phid,
$awaiting_user_phid);
}
$status = $this->auditStatus;
if ($status !== null) {
switch ($status) {
case self::AUDIT_STATUS_CONCERN:
$where[] = qsprintf(
$conn_r,
'audit.auditStatus = %s',
PhabricatorAuditStatusConstants::CONCERNED);
break;
case self::AUDIT_STATUS_OPEN:
$where[] = qsprintf(
$conn_r,
'audit.auditStatus in (%Ls)',
PhabricatorAuditStatusConstants::getOpenStatusConstants());
if ($this->auditAwaitingUser) {
$where[] = qsprintf(
$conn_r,
'awaiting.auditStatus IS NULL OR awaiting.auditStatus != %s',
PhabricatorAuditStatusConstants::RESIGNED);
}
break;
case self::AUDIT_STATUS_ANY:
break;
default:
$valid = array(
self::AUDIT_STATUS_ANY,
self::AUDIT_STATUS_OPEN,
self::AUDIT_STATUS_CONCERN,
);
throw new Exception(
"Unknown audit status '{$status}'! Valid statuses are: ".
implode(', ', $valid));
}
}
$where[] = $this->buildPagingClause($conn_r);
return $this->formatWhereClause($where);
}
public function didFilterResults(array $filtered) {
if ($this->identifierMap) {
foreach ($this->identifierMap as $name => $commit) {
if (isset($filtered[$commit->getPHID()])) {
unset($this->identifierMap[$name]);
}
}
}
}
private function buildJoinClause($conn_r) {
$joins = array();
$audit_request = new PhabricatorRepositoryAuditRequest();
if ($this->shouldJoinAudits()) {
$joins[] = qsprintf(
$conn_r,
'%Q %T audit ON commit.phid = audit.commitPHID',
($this->rowsMustHaveAudits() ? 'JOIN' : 'LEFT JOIN'),
$audit_request->getTableName());
}
if ($this->auditAwaitingUser) {
// Join the request table on the awaiting user's requests, so we can
// filter out package and project requests which the user has resigned
// from.
$joins[] = qsprintf(
$conn_r,
'LEFT JOIN %T awaiting ON audit.commitPHID = awaiting.commitPHID AND
awaiting.auditorPHID = %s',
$audit_request->getTableName(),
$this->auditAwaitingUser->getPHID());
}
if ($joins) {
return implode(' ', $joins);
} else {
return '';
}
}
+ private function buildGroupClause(AphrontDatabaseConnection $conn_r) {
+ $should_group = $this->shouldJoinAudits();
+
+ // TODO: Currently, the audit table is missing a unique key, so we may
+ // require a GROUP BY if we perform this join. See T1768. This can be
+ // removed once the table has the key.
+ if ($this->auditAwaitingUser) {
+ $should_group = true;
+ }
+
+ if ($should_group) {
+ return 'GROUP BY commit.id';
+ } else {
+ return '';
+ }
+ }
+
public function getQueryApplicationClass() {
return 'PhabricatorApplicationDiffusion';
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sun, Jan 19, 16:40 (2 w, 5 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1126568
Default Alt Text
(27 KB)

Event Timeline