Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2891070
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Advanced/Developer...
View Handle
View Hovercard
Size
27 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/conf/aphlict/aphlict.default.json b/conf/aphlict/aphlict.default.json
index 1f1bafc3ea..7afdf7e8ff 100644
--- a/conf/aphlict/aphlict.default.json
+++ b/conf/aphlict/aphlict.default.json
@@ -1,24 +1,26 @@
{
"servers": [
{
"type": "client",
"port": 22280,
"listen": "0.0.0.0",
"ssl.key": null,
- "ssl.cert": null
+ "ssl.cert": null,
+ "ssl.chain": null
},
{
"type": "admin",
"port": 22281,
"listen": "127.0.0.1",
"ssl.key": null,
- "ssl.cert": null
+ "ssl.cert": null,
+ "ssl.chain": null
}
],
"logs": [
{
"path": "/var/log/aphlict.log"
}
],
"pidfile": "/var/tmp/aphlict/pid/aphlict.pid"
}
diff --git a/src/applications/aphlict/management/PhabricatorAphlictManagementWorkflow.php b/src/applications/aphlict/management/PhabricatorAphlictManagementWorkflow.php
index 60d34b89eb..8d8e1edf77 100644
--- a/src/applications/aphlict/management/PhabricatorAphlictManagementWorkflow.php
+++ b/src/applications/aphlict/management/PhabricatorAphlictManagementWorkflow.php
@@ -1,454 +1,470 @@
<?php
abstract class PhabricatorAphlictManagementWorkflow
extends PhabricatorManagementWorkflow {
private $debug = false;
private $configData;
private $configPath;
final protected function setDebug($debug) {
$this->debug = $debug;
return $this;
}
protected function getLaunchArguments() {
return array(
array(
'name' => 'config',
'param' => 'file',
'help' => pht(
'Use a specific configuration file instead of the default '.
'configuration.'),
),
);
}
protected function parseLaunchArguments(PhutilArgumentParser $args) {
$config_file = $args->getArg('config');
if ($config_file) {
$full_path = Filesystem::resolvePath($config_file);
$show_path = $full_path;
} else {
$root = dirname(dirname(phutil_get_library_root('phabricator')));
$try = array(
'phabricator/conf/aphlict/aphlict.custom.json',
'phabricator/conf/aphlict/aphlict.default.json',
);
foreach ($try as $config) {
$full_path = $root.'/'.$config;
$show_path = $config;
if (Filesystem::pathExists($full_path)) {
break;
}
}
}
echo tsprintf(
"%s\n",
pht(
'Reading configuration from: %s',
$show_path));
try {
$data = Filesystem::readFile($full_path);
} catch (Exception $ex) {
throw new PhutilArgumentUsageException(
pht(
'Failed to read configuration file. %s',
$ex->getMessage()));
}
try {
$data = phutil_json_decode($data);
} catch (Exception $ex) {
throw new PhutilArgumentUsageException(
pht(
'Configuration file is not properly formatted JSON. %s',
$ex->getMessage()));
}
try {
PhutilTypeSpec::checkMap(
$data,
array(
'servers' => 'list<wild>',
'logs' => 'optional list<wild>',
'pidfile' => 'string',
));
} catch (Exception $ex) {
throw new PhutilArgumentUsageException(
pht(
'Configuration file has improper configuration keys at top '.
'level. %s',
$ex->getMessage()));
}
$servers = $data['servers'];
$has_client = false;
$has_admin = false;
$port_map = array();
foreach ($servers as $index => $server) {
PhutilTypeSpec::checkMap(
$server,
array(
'type' => 'string',
'port' => 'int',
'listen' => 'optional string|null',
'ssl.key' => 'optional string|null',
'ssl.cert' => 'optional string|null',
+ 'ssl.chain' => 'optional string|null',
));
$port = $server['port'];
if (!isset($port_map[$port])) {
$port_map[$port] = $index;
} else {
throw new PhutilArgumentUsageException(
pht(
'Two servers (at indexes "%s" and "%s") both bind to the same '.
'port ("%s"). Each server must bind to a unique port.',
$port_map[$port],
$index,
$port));
}
$type = $server['type'];
switch ($type) {
case 'admin':
$has_admin = true;
break;
case 'client':
$has_client = true;
break;
default:
throw new PhutilArgumentUsageException(
pht(
'A specified server (at index "%s", on port "%s") has an '.
'invalid type ("%s"). Valid types are: admin, client.',
$index,
$port,
$type));
}
$ssl_key = idx($server, 'ssl.key');
$ssl_cert = idx($server, 'ssl.cert');
if (($ssl_key && !$ssl_cert) || ($ssl_cert && !$ssl_key)) {
throw new PhutilArgumentUsageException(
pht(
'A specified server (at index "%s", on port "%s") specifies '.
'only one of "%s" and "%s". Each server must specify neither '.
'(to disable SSL) or specify both (to enable it).',
$index,
$port,
'ssl.key',
'ssl.cert'));
}
+
+ $ssl_chain = idx($server, 'ssl.chain');
+ if ($ssl_chain && (!$ssl_key && !$ssl_cert)) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'A specified server (at index "%s", on port "%s") specifies '.
+ 'a value for "%s", but no value for "%s" or "%s". Servers '.
+ 'should only provide an SSL chain if they also provide an SSL '.
+ 'key and SSL certificate.',
+ $index,
+ $port,
+ 'ssl.chain',
+ 'ssl.key',
+ 'ssl.cert'));
+ }
}
if (!$servers) {
throw new PhutilArgumentUsageException(
pht(
'Configuration file does not specify any servers. This service '.
'will not be able to interact with the outside world if it does '.
'not listen on any ports. You must specify at least one "%s" '.
'server and at least one "%s" server.',
'admin',
'client'));
}
if (!$has_client) {
throw new PhutilArgumentUsageException(
pht(
'Configuration file does not specify any client servers. This '.
'service will be unable to transmit any notifications without a '.
'client server. You must specify at least one server with '.
'type "%s".',
'client'));
}
if (!$has_admin) {
throw new PhutilArgumentUsageException(
pht(
'Configuration file does not specify any administrative '.
'servers. This service will be unable to receive messages. '.
'You must specify at least one server with type "%s".',
'admin'));
}
$logs = $data['logs'];
foreach ($logs as $index => $log) {
PhutilTypeSpec::checkMap(
$log,
array(
'path' => 'string',
));
$path = $log['path'];
try {
$dir = dirname($path);
if (!Filesystem::pathExists($dir)) {
Filesystem::createDirectory($dir, 0755, true);
}
} catch (FilesystemException $ex) {
throw new PhutilArgumentUsageException(
pht(
'Failed to create directory "%s" for specified log file (with '.
'index "%s"). You should manually create this directory or '.
'choose a different logfile location. %s',
$dir,
$ex->getMessage()));
}
}
$this->configData = $data;
$this->configPath = $full_path;
$pid_path = $this->getPIDPath();
try {
$dir = dirname($path);
if (!Filesystem::pathExists($dir)) {
Filesystem::createDirectory($dir, 0755, true);
}
} catch (FilesystemException $ex) {
throw new PhutilArgumentUsageException(
pht(
'Failed to create directory "%s" for specified PID file. You '.
'should manually create this directory or choose a different '.
'PID file location. %s',
$dir,
$ex->getMessage()));
}
}
final public function getPIDPath() {
return $this->configData['pidfile'];
}
final public function getPID() {
$pid = null;
if (Filesystem::pathExists($this->getPIDPath())) {
$pid = (int)Filesystem::readFile($this->getPIDPath());
}
return $pid;
}
final public function cleanup($signo = '?') {
global $g_future;
if ($g_future) {
$g_future->resolveKill();
$g_future = null;
}
Filesystem::remove($this->getPIDPath());
exit(1);
}
public static function requireExtensions() {
self::mustHaveExtension('pcntl');
self::mustHaveExtension('posix');
}
private static function mustHaveExtension($ext) {
if (!extension_loaded($ext)) {
echo pht(
"ERROR: The PHP extension '%s' is not installed. You must ".
"install it to run Aphlict on this machine.",
$ext)."\n";
exit(1);
}
$extension = new ReflectionExtension($ext);
foreach ($extension->getFunctions() as $function) {
$function = $function->name;
if (!function_exists($function)) {
echo pht(
'ERROR: The PHP function %s is disabled. You must '.
'enable it to run Aphlict on this machine.',
$function.'()')."\n";
exit(1);
}
}
}
final protected function willLaunch() {
$console = PhutilConsole::getConsole();
$pid = $this->getPID();
if ($pid) {
throw new PhutilArgumentUsageException(
pht(
'Unable to start notifications server because it is already '.
'running. Use `%s` to restart it.',
'aphlict restart'));
}
if (posix_getuid() == 0) {
throw new PhutilArgumentUsageException(
pht('The notification server should not be run as root.'));
}
// Make sure we can write to the PID file.
if (!$this->debug) {
Filesystem::writeFile($this->getPIDPath(), '');
}
// First, start the server in configuration test mode with --test. This
// will let us error explicitly if there are missing modules, before we
// fork and lose access to the console.
$test_argv = $this->getServerArgv();
$test_argv[] = '--test=true';
execx('%C', $this->getStartCommand($test_argv));
}
private function getServerArgv() {
$server_argv = array();
$server_argv[] = '--config='.$this->configPath;
return $server_argv;
}
final protected function launch() {
$console = PhutilConsole::getConsole();
if ($this->debug) {
$console->writeOut(
"%s\n",
pht('Starting Aphlict server in foreground...'));
} else {
Filesystem::writeFile($this->getPIDPath(), getmypid());
}
$command = $this->getStartCommand($this->getServerArgv());
if (!$this->debug) {
declare(ticks = 1);
pcntl_signal(SIGINT, array($this, 'cleanup'));
pcntl_signal(SIGTERM, array($this, 'cleanup'));
}
register_shutdown_function(array($this, 'cleanup'));
if ($this->debug) {
$console->writeOut(
"%s\n\n $ %s\n\n",
pht('Launching server:'),
$command);
$err = phutil_passthru('%C', $command);
$console->writeOut(">>> %s\n", pht('Server exited!'));
exit($err);
} else {
while (true) {
global $g_future;
$g_future = new ExecFuture('exec %C', $command);
$g_future->resolve();
// If the server exited, wait a couple of seconds and restart it.
unset($g_future);
sleep(2);
}
}
}
/* -( Commands )----------------------------------------------------------- */
final protected function executeStartCommand() {
$console = PhutilConsole::getConsole();
$this->willLaunch();
$pid = pcntl_fork();
if ($pid < 0) {
throw new Exception(
pht(
'Failed to %s!',
'fork()'));
} else if ($pid) {
$console->writeErr("%s\n", pht('Aphlict Server started.'));
exit(0);
}
// When we fork, the child process will inherit its parent's set of open
// file descriptors. If the parent process of bin/aphlict is waiting for
// bin/aphlict's file descriptors to close, it will be stuck waiting on
// the daemonized process. (This happens if e.g. bin/aphlict is started
// in another script using passthru().)
fclose(STDOUT);
fclose(STDERR);
$this->launch();
return 0;
}
final protected function executeStopCommand() {
$console = PhutilConsole::getConsole();
$pid = $this->getPID();
if (!$pid) {
$console->writeErr("%s\n", pht('Aphlict is not running.'));
return 0;
}
$console->writeErr("%s\n", pht('Stopping Aphlict Server (%s)...', $pid));
posix_kill($pid, SIGINT);
$start = time();
do {
if (!PhabricatorDaemonReference::isProcessRunning($pid)) {
$console->writeOut(
"%s\n",
pht('Aphlict Server (%s) exited normally.', $pid));
$pid = null;
break;
}
usleep(100000);
} while (time() < $start + 5);
if ($pid) {
$console->writeErr("%s\n", pht('Sending %s a SIGKILL.', $pid));
posix_kill($pid, SIGKILL);
unset($pid);
}
Filesystem::remove($this->getPIDPath());
return 0;
}
private function getNodeBinary() {
if (Filesystem::binaryExists('nodejs')) {
return 'nodejs';
}
if (Filesystem::binaryExists('node')) {
return 'node';
}
throw new PhutilArgumentUsageException(
pht(
'No `%s` or `%s` binary was found in %s. You must install '.
'Node.js to start the Aphlict server.',
'nodejs',
'node',
'$PATH'));
}
private function getAphlictScriptPath() {
$root = dirname(phutil_get_library_root('phabricator'));
return $root.'/support/aphlict/server/aphlict_server.js';
}
private function getStartCommand(array $server_argv) {
return csprintf(
'%s %s %Ls',
$this->getNodeBinary(),
$this->getAphlictScriptPath(),
$server_argv);
}
}
diff --git a/src/docs/user/configuration/notifications.diviner b/src/docs/user/configuration/notifications.diviner
index 960b931b66..6a669eaef1 100644
--- a/src/docs/user/configuration/notifications.diviner
+++ b/src/docs/user/configuration/notifications.diviner
@@ -1,262 +1,264 @@
@title Notifications User Guide: Setup and Configuration
@group config
Guide to setting up notifications.
Overview
========
By default, Phabricator delivers information about events (like users creating
tasks or commenting on code reviews) through email and in-application
notifications.
Phabricator can also be configured to deliver notifications in real time, by
popping up a message in any open browser windows if something has happened or
an object has been updated.
To enable real-time notifications:
- Configure and start the notification server, as described below.
- Adjust `notification.servers` to point at it.
This document describes the process in detail.
Supported Browsers
==================
Notifications are supported for browsers which support WebSockets. This covers
most modern browsers (like Chrome, Firefox, Safari, and recent versions of
Internet Explorer) and many mobile browsers.
IE8 and IE9 do not support WebSockets, so real-time notifications won't work in
those browsers.
Installing Node and Modules
===========================
The notification server uses Node.js, so you'll need to install it first.
To install Node.js, follow the instructions on
[[ http://nodejs.org | nodejs.org ]].
You will also need to install the `ws` module for Node. This needs to be
installed into the notification server directory:
phabricator/ $ cd support/aphlict/server/
phabricator/support/aphlict/server/ $ npm install ws
Once Node.js and the `ws` module are installed, you're ready to start the
server.
Running the Aphlict Server
==========================
After installing Node.js, you can control the notification server with the
`bin/aphlict` command. To start the server:
phabricator/ $ bin/aphlict start
By default, the server must be able to listen on port `22280`. If you're using
a host firewall (like a security group in EC2), make sure traffic can reach the
server.
The server configuration is controlled by a configuration file, which is
separate from Phabricator's configuration settings. The default file can
be found at `phabricator/conf/aphlict/aphlict.default.json`.
To make adjustments to the default configuration, either copy this file to
create `aphlict.custom.json` in the same directory (this file will be used if
it exists) or specify a configuration file explicitly with the `--config` flag:
phabricator/ $ bin/aphlict start --config path/to/config.json
The configuration file has these settings:
- `servers`: //Required list.// A list of servers to start.
- `logs`: //Optional list.// A list of logs to write to.
- `pidfile`: //Required string.// Path to a PID file.
Each server in the `servers` list should be an object with these keys:
- `type`: //Required string.// The type of server to start. Options are
`admin` or `client`. Normally, you should run one of each.
- `port`: //Required int.// The port this server should listen on.
- `listen`: //Optional string.// Which interface to bind to. By default,
- the `admin` server is bound to localhost (so only other services on the
+ the `admin` server is bound to `127.0.0.1` (so only other services on the
local machine can connect to it), while the `client` server is bound
- to `0.0.0.0` (so any client can connect.
+ to `0.0.0.0` (so any client can connect).
- `ssl.key`: //Optional string.// If you want to use SSL on this port,
the path to an SSL key.
- `ssl.cert`: //Optional string.// If you want to use SSL on this port,
the path to an SSL certificate.
+ - `ssl.chain`: //Optional string.// If you have configured SSL on this
+ port, an optional path to a certificate chain file.
Each log in the `logs` list should be an object with these keys:
- `path`: //Required string.// Path to the log file.
The defaults are appropriate for simple cases, but you may need to adjust them
if you are running a more complex configuration.
Configuring Phabricator
=======================
After starting the server, configure Phabricator to connect to it by adjusting
`notification.servers`. This configuration option should have a list of servers
that Phabricator should interact with.
Normally, you'll list one client server and one admin server, like this:
```lang=json
[
{
"type": "client",
"host": "phabricator.mycompany.com",
"port": 22280,
"protocol": "https"
},
{
"type": "admin",
"host": "127.0.0.1",
"port": 22281,
"protocol": "http"
}
]
```
This definition defines which services the user's browser will attempt to
connect to. Most of the time, it will be very similar to the services defined
in the Aphlict configuration. However, if you are sending traffic through a
load balancer or terminating SSL somewhere before traffic reaches Aphlict,
the services the browser connects to may need to have different hosts, ports
or protocols than the underlying server listens on.
Verifying Server Status
=======================
After configuring `notification.servers`, navigate to
{nav Config > Notification Servers} to verify that things are operational.
Troubleshooting
===============
You can run `aphlict` in the foreground to get output to your console:
phabricator/ $ ./bin/aphlict debug
Because the notification server uses WebSockets, your browser error console
may also have information that is useful in figuring out what's wrong.
The server also generates a log, by default in `/var/log/aphlict.log`. You can
change this location by adjusting configuration. The log may contain
information that is useful in resolving issues.
SSL and HTTPS
=============
If you serve Phabricator over HTTPS, you must also serve websockets over HTTPS.
Browsers will refuse to connect to `ws://` websockets from HTTPS pages.
If a client connects to Phabricator over HTTPS, Phabricator will automatically
select an appropriate HTTPS service from `notification.servers` and instruct
the browser to open a websocket connection with `wss://`.
The simplest way to do this is configure Aphlict with an SSL key and
certificate and let it terminate SSL directly.
If you prefer not to do this, two other options are:
- run the websocket through a websocket-capable loadbalancer and terminate
SSL there; or
- run the websocket through `nginx` over the same socket as the rest of
your web traffic.
See the next sections for more detail.
Terminating SSL with a Load Balancer
====================================
If you want to terminate SSL in front of the notification server with a
traditional load balancer or a similar device, do this:
- Point `notification.servers` at your load balancer or reverse proxy,
specifying that the protocol is `https`.
- On the load balancer or proxy, terminate SSL and forward traffic to the
Aphlict server.
- In the Aphlict configuration, listen on the target port with `http`.
Terminating SSL with Nginx
==========================
If you use `nginx`, you can send websocket traffic to the same port as normal
HTTP traffic and have `nginx` proxy it selectively based on the request path.
This requires `nginx` 1.3 or greater. See the `nginx` documentation for
details:
> http://nginx.com/blog/websocket-nginx/
This is very complex, but allows you to support notifications without opening
additional ports.
An example `nginx` configuration might look something like this:
```lang=nginx, name=/etc/nginx/conf.d/connection_upgrade.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
```
```lang=nginx, name=/etc/nginx/conf.d/websocket_pool.conf
upstream websocket_pool {
ip_hash;
server 127.0.0.1:22280;
}
```
```lang=nginx, name=/etc/nginx/sites-enabled/phabricator.example.com.conf
server {
server_name phabricator.example.com;
root /path/to/phabricator/webroot;
// ...
location = /ws/ {
proxy_pass http://websocket_pool;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 999999999;
}
}
```
With this approach, you should make these additional adjustments:
**Phabricator Configuration**: The entry in `notification.servers` with type
`"client"` should have these adjustments made:
- Set `host` to the Phabricator host.
- Set `port` to the standard HTTPS port (usually `443`).
- Set `protocol` to `"https"`.
- Set `path` to `/ws/`, so it matches the special `location` in your
`nginx` config.
You do not need to adjust the `"admin"` server.
**Aphlict**: Your Aphlict configuration should make these adjustments to
the `"client"` server:
- The `protocol` should be `"http"`: `nginx` will send plain HTTP traffic
to Aphlict.
- Optionally, you can `listen` on `127.0.0.1` instead of `0.0.0.0`, because
the server will no longer receive external traffic.
diff --git a/support/aphlict/server/aphlict_server.js b/support/aphlict/server/aphlict_server.js
index 263a355542..2c03875c7d 100644
--- a/support/aphlict/server/aphlict_server.js
+++ b/support/aphlict/server/aphlict_server.js
@@ -1,171 +1,179 @@
'use strict';
var JX = require('./lib/javelin').JX;
var http = require('http');
var https = require('https');
var util = require('util');
var fs = require('fs');
function parse_command_line_arguments(argv) {
var args = {
test: false,
config: null
};
for (var ii = 2; ii < argv.length; ii++) {
var arg = argv[ii];
var matches = arg.match(/^--([^=]+)=(.*)$/);
if (!matches) {
throw new Error('Unknown argument "' + arg + '"!');
}
if (!(matches[1] in args)) {
throw new Error('Unknown argument "' + matches[1] + '"!');
}
args[matches[1]] = matches[2];
}
return args;
}
function parse_config(args) {
var data = fs.readFileSync(args.config);
return JSON.parse(data);
}
require('./lib/AphlictLog');
var debug = new JX.AphlictLog()
.addConsole(console);
var args = parse_command_line_arguments(process.argv);
var config = parse_config(args);
function set_exit_code(code) {
process.on('exit', function() {
process.exit(code);
});
}
process.on('uncaughtException', function(err) {
var context = null;
if (err.code == 'EACCES') {
context = util.format(
'Unable to open file ("%s"). Check that permissions are set ' +
'correctly.',
err.path);
}
var message = [
'\n<<< UNCAUGHT EXCEPTION! >>>',
];
if (context) {
message.push(context);
}
message.push(err.stack);
debug.log(message.join('\n\n'));
set_exit_code(1);
});
try {
require('ws');
} catch (ex) {
throw new Error(
'You need to install the Node.js "ws" module for websocket support. ' +
'See "Notifications User Guide: Setup and Configuration" in the ' +
'documentation for instructions. ' + ex.toString());
}
// NOTE: Require these only after checking for the "ws" module, since they
// depend on it.
require('./lib/AphlictAdminServer');
require('./lib/AphlictClientServer');
var ii;
var logs = config.logs || [];
for (ii = 0; ii < logs.length; ii++) {
debug.addLog(logs[ii].path);
}
var servers = [];
for (ii = 0; ii < config.servers.length; ii++) {
var spec = config.servers[ii];
spec.listen = spec.listen || '0.0.0.0';
if (spec['ssl.key']) {
spec['ssl.key'] = fs.readFileSync(spec['ssl.key']);
}
if (spec['ssl.cert']){
spec['ssl.cert'] = fs.readFileSync(spec['ssl.cert']);
}
+ if (spec['ssl.chain']){
+ spec['ssl.chain'] = fs.readFileSync(spec['ssl.chain']);
+ }
+
servers.push(spec);
}
// If we're just doing a configuration test, exit here before starting any
// servers.
if (args.test) {
debug.log('Configuration test OK.');
set_exit_code(0);
return;
}
debug.log('Starting servers (service PID %d).', process.pid);
for (ii = 0; ii < logs.length; ii++) {
debug.log('Logging to "%s".', logs[ii].path);
}
var aphlict_servers = [];
var aphlict_clients = [];
var aphlict_admins = [];
for (ii = 0; ii < servers.length; ii++) {
var server = servers[ii];
var is_client = (server.type == 'client');
var http_server;
if (server['ssl.key']) {
var https_config = {
key: server['ssl.key'],
- cert: server['ssl.cert']
+ cert: server['ssl.cert'],
};
+ if (server['ssl.chain']) {
+ https_config.ca = server['ssl.chain'];
+ }
+
http_server = https.createServer(https_config);
} else {
http_server = http.createServer();
}
var aphlict_server;
if (is_client) {
aphlict_server = new JX.AphlictClientServer(http_server);
} else {
aphlict_server = new JX.AphlictAdminServer(http_server);
}
aphlict_server.setLogger(debug);
aphlict_server.listen(server.port, server.listen);
debug.log(
'Started %s server (Port %d, %s).',
server.type,
server.port,
server['ssl.key'] ? 'With SSL' : 'No SSL');
aphlict_servers.push(aphlict_server);
if (is_client) {
aphlict_clients.push(aphlict_server);
} else {
aphlict_admins.push(aphlict_server);
}
}
for (ii = 0; ii < aphlict_admins.length; ii++) {
var admin_server = aphlict_admins[ii];
admin_server.setClientServers(aphlict_clients);
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Jan 19, 14:33 (3 w, 2 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1125552
Default Alt Text
(27 KB)
Attached To
Mode
rP Phorge
Attached
Detach File
Event Timeline
Log In to Comment