Page Menu
Home
WickedGov Phorge
Search
Configure Global Search
Log In
Files
F4047262
LuaStandaloneInterpreter.php
No One
Temporary
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
18 KB
Referenced Files
None
Subscribers
None
LuaStandaloneInterpreter.php
View Options
<?php
namespace
MediaWiki\Extension\Scribunto\Engines\LuaStandalone
;
use
InvalidArgumentException
;
use
MediaWiki\Extension\Scribunto\Engines\LuaCommon\LuaError
;
use
MediaWiki\Extension\Scribunto\Engines\LuaCommon\LuaInterpreter
;
use
MediaWiki\Extension\Scribunto\Engines\LuaCommon\LuaInterpreterNotExecutableError
;
use
MediaWiki\Extension\Scribunto\Engines\LuaCommon\LuaInterpreterNotFoundError
;
use
MediaWiki\Extension\Scribunto\ScribuntoException
;
use
Psr\Log\LoggerInterface
;
use
Psr\Log\NullLogger
;
use
RuntimeException
;
use
UtfNormal\Validator
;
class
LuaStandaloneInterpreter
extends
LuaInterpreter
{
/** @var int */
protected
static
$nextInterpreterId
=
0
;
/**
* @var LuaStandaloneEngine
*/
public
$engine
;
/**
* @var bool
*/
public
$enableDebug
;
/**
* @var resource|bool
*/
public
$proc
;
/**
* @var resource
*/
public
$writePipe
;
/**
* @var resource
*/
public
$readPipe
;
/**
* @var ScribuntoException
*/
public
$exitError
;
/**
* @var int
*/
public
$id
;
/**
* @var LoggerInterface
*/
protected
$logger
;
/**
* @var callable[]
*/
protected
$callbacks
;
/**
* @param LuaStandaloneEngine $engine
* @param array $options
* @throws LuaInterpreterNotFoundError
* @throws ScribuntoException
*/
public
function
__construct
(
$engine
,
array
$options
)
{
$this
->
id
=
self
::
$nextInterpreterId
++;
if
(
$options
[
'errorFile'
]
===
null
)
{
$options
[
'errorFile'
]
=
wfGetNull
();
}
if
(
$options
[
'luaPath'
]
===
null
)
{
$path
=
false
;
// Note, if you alter these, also alter getLuaVersion() below
if
(
PHP_OS
==
'Linux'
)
{
if
(
PHP_INT_SIZE
==
4
)
{
$path
=
'lua5_1_5_linux_32_generic/lua'
;
}
elseif
(
PHP_INT_SIZE
==
8
)
{
$path
=
'lua5_1_5_linux_64_generic/lua'
;
}
}
elseif
(
PHP_OS
==
'Windows'
||
PHP_OS
==
'WINNT'
||
PHP_OS
==
'Win32'
)
{
if
(
PHP_INT_SIZE
==
4
)
{
$path
=
'lua5_1_5_Win32_bin/lua5.1.exe'
;
}
elseif
(
PHP_INT_SIZE
==
8
)
{
$path
=
'lua5_1_5_Win64_bin/lua5.1.exe'
;
}
}
elseif
(
PHP_OS
==
'Darwin'
)
{
$path
=
'lua5_1_5_mac_lion_fat_generic/lua'
;
}
if
(
$path
===
false
)
{
throw
new
LuaInterpreterNotFoundError
(
'No Lua interpreter was given in the configuration, '
.
'and no bundled binary exists for this platform.'
);
}
$options
[
'luaPath'
]
=
__DIR__
.
"/binaries/$path"
;
if
(
!
is_executable
(
$options
[
'luaPath'
]
)
)
{
throw
new
LuaInterpreterNotExecutableError
(
sprintf
(
'The lua binary (%s) is not executable.'
,
$options
[
'luaPath'
]
)
);
}
}
$this
->
engine
=
$engine
;
$this
->
enableDebug
=
!
empty
(
$options
[
'debug'
]
);
$this
->
logger
=
$options
[
'logger'
]
??
new
NullLogger
();
$pipes
=
null
;
$cmd
=
wfEscapeShellArg
(
$options
[
'luaPath'
],
__DIR__
.
'/mw_main.lua'
,
dirname
(
dirname
(
__DIR__
)
),
(
string
)
$this
->
id
,
(
string
)
PHP_INT_SIZE
);
if
(
php_uname
(
's'
)
==
'Linux'
)
{
// Limit memory and CPU
$cmd
=
wfEscapeShellArg
(
# proc_open() passes $cmd to 'sh -c' on Linux, so add an 'exec' to bypass it
'exec'
,
'/bin/sh'
,
__DIR__
.
'/lua_ulimit.sh'
,
# soft limit (SIGXCPU)
(
string
)
$options
[
'cpuLimit'
],
# hard limit
(
string
)(
$options
[
'cpuLimit'
]
+
1
),
(
string
)
intval
(
$options
[
'memoryLimit'
]
/
1024
),
$cmd
);
}
if
(
php_uname
(
's'
)
==
'Windows NT'
)
{
// Like the passthru() in older versions of PHP,
// PHP's invokation of cmd.exe in proc_open() is broken:
// http://news.php.net/php.internals/21796
// Unlike passthru(), it is not fixed in any PHP version,
// so we use the fix similar to one in wfShellExec()
$cmd
=
'"'
.
$cmd
.
'"'
;
}
$this
->
logger
->
debug
(
__METHOD__
.
": creating interpreter: $cmd"
);
// Check whether proc_open is available before trying to call it (e.g.
// PHP's disable_functions may have removed it)
if
(
!
function_exists
(
'proc_open'
)
)
{
throw
$this
->
engine
->
newException
(
'scribunto-luastandalone-proc-error-proc-open'
);
}
// Clear the "last error", so if proc_open fails we can know any
// warning was generated by that.
error_clear_last
();
// phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.proc_open
$this
->
proc
=
proc_open
(
$cmd
,
[
[
'pipe'
,
'r'
],
[
'pipe'
,
'w'
],
[
'file'
,
$options
[
'errorFile'
],
'a'
]
],
$pipes
);
if
(
!
$this
->
proc
)
{
$err
=
error_get_last
();
if
(
!
empty
(
$err
[
'message'
]
)
)
{
throw
$this
->
engine
->
newException
(
'scribunto-luastandalone-proc-error-msg'
,
[
'args'
=>
[
$err
[
'message'
]
]
]
);
}
else
{
throw
$this
->
engine
->
newException
(
'scribunto-luastandalone-proc-error'
);
}
}
$this
->
writePipe
=
$pipes
[
0
];
$this
->
readPipe
=
$pipes
[
1
];
}
public
function
__destruct
()
{
$this
->
terminate
();
}
/**
* Fetch the Lua version
* @param array $options Engine options
* @return string|null
*/
public
static
function
getLuaVersion
(
array
$options
)
{
if
(
$options
[
'luaPath'
]
===
null
)
{
// We know which versions are distributed, no need to run them.
if
(
PHP_OS
==
'Linux'
)
{
return
'Lua 5.1.5'
;
}
elseif
(
PHP_OS
==
'Windows'
||
PHP_OS
==
'WINNT'
||
PHP_OS
==
'Win32'
)
{
return
'Lua 5.1.5'
;
}
elseif
(
PHP_OS
==
'Darwin'
)
{
return
'Lua 5.1.5'
;
}
else
{
return
null
;
}
}
// Ask the interpreter what version it is, using the "-v" option.
// The output is expected to be one line, something like these:
// Lua 5.1.5 Copyright (C) 1994-2012 Lua.org, PUC-Rio
// LuaJIT 2.0.0 -- Copyright (C) 2005-2012 Mike Pall. http://luajit.org/
$cmd
=
wfEscapeShellArg
(
$options
[
'luaPath'
]
)
.
' -v 2>&1'
;
// phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.popen
$handle
=
popen
(
$cmd
,
'r'
);
if
(
$handle
)
{
$ret
=
fgets
(
$handle
,
80
);
pclose
(
$handle
);
if
(
$ret
&&
preg_match
(
'/^Lua(?:JIT)?
\S
+/'
,
$ret
,
$m
)
)
{
return
$m
[
0
];
}
}
return
null
;
}
public
function
terminate
()
{
if
(
$this
->
proc
)
{
$this
->
logger
->
debug
(
__METHOD__
.
": terminating"
);
proc_terminate
(
$this
->
proc
);
proc_close
(
$this
->
proc
);
$this
->
proc
=
false
;
}
}
public
function
quit
()
{
if
(
!
$this
->
proc
)
{
return
;
}
$this
->
dispatch
(
[
'op'
=>
'quit'
]
);
proc_close
(
$this
->
proc
);
}
public
function
testquit
()
{
if
(
!
$this
->
proc
)
{
return
;
}
$this
->
dispatch
(
[
'op'
=>
'testquit'
]
);
proc_close
(
$this
->
proc
);
}
/**
* @param string $text
* @param string $chunkName
* @return LuaStandaloneInterpreterFunction
*/
public
function
loadString
(
$text
,
$chunkName
)
{
$this
->
cleanupLuaChunks
();
$result
=
$this
->
dispatch
(
[
'op'
=>
'loadString'
,
'text'
=>
$text
,
'chunkName'
=>
$chunkName
]
);
return
new
LuaStandaloneInterpreterFunction
(
$this
->
id
,
$result
[
1
]
);
}
/** @inheritDoc */
public
function
callFunction
(
$func
,
...
$args
)
{
if
(
!(
$func
instanceof
LuaStandaloneInterpreterFunction
)
)
{
throw
new
InvalidArgumentException
(
__METHOD__
.
': invalid function type'
);
}
if
(
$func
->
interpreterId
!==
$this
->
id
)
{
throw
new
InvalidArgumentException
(
__METHOD__
.
': function belongs to a different interpreter'
);
}
$args
=
func_get_args
();
unset
(
$args
[
0
]
);
// $args is now conveniently a 1-based array, as required by the Lua server
$this
->
cleanupLuaChunks
();
$result
=
$this
->
dispatch
(
[
'op'
=>
'call'
,
'id'
=>
$func
->
id
,
'nargs'
=>
count
(
$args
),
'args'
=>
$args
,
]
);
// Convert return values to zero-based
return
array_values
(
$result
);
}
/** @inheritDoc */
public
function
wrapPhpFunction
(
$callable
)
{
static
$uid
=
0
;
$id
=
"anonymous*"
.
++
$uid
;
$this
->
callbacks
[
$id
]
=
$callable
;
$ret
=
$this
->
dispatch
(
[
'op'
=>
'wrapPhpFunction'
,
'id'
=>
$id
,
]
);
return
$ret
[
1
];
}
public
function
cleanupLuaChunks
()
{
if
(
isset
(
LuaStandaloneInterpreterFunction
::
$anyChunksDestroyed
[
$this
->
id
]
)
)
{
unset
(
LuaStandaloneInterpreterFunction
::
$anyChunksDestroyed
[
$this
->
id
]
);
$this
->
dispatch
(
[
'op'
=>
'cleanupChunks'
,
'ids'
=>
LuaStandaloneInterpreterFunction
::
$activeChunkIds
[
$this
->
id
]
]
);
}
}
/** @inheritDoc */
public
function
isLuaFunction
(
$object
)
{
return
$object
instanceof
LuaStandaloneInterpreterFunction
;
}
/** @inheritDoc */
public
function
registerLibrary
(
$name
,
array
$functions
)
{
// Make sure all ids are unique, even when libraries share the same name
// which is especially relevant for "mw_interface" (T211203).
static
$uid
=
0
;
$uid
++;
$functionIds
=
[];
foreach
(
$functions
as
$funcName
=>
$callback
)
{
$id
=
"$name-$funcName-$uid"
;
$this
->
callbacks
[
$id
]
=
$callback
;
$functionIds
[
$funcName
]
=
$id
;
}
$this
->
dispatch
(
[
'op'
=>
'registerLibrary'
,
'name'
=>
$name
,
'functions'
=>
$functionIds
,
]
);
}
/**
* Get interpreter status
* @return array
*/
public
function
getStatus
()
{
$result
=
$this
->
dispatch
(
[
'op'
=>
'getStatus'
,
]
);
return
$result
[
1
];
}
public
function
pauseUsageTimer
()
{
}
public
function
unpauseUsageTimer
()
{
}
/**
* Fill in missing nulls in a list received from Lua
*
* @param array $array List received from Lua
* @param int $count Number of values that should be in the list
* @return array Non-sparse array
*/
private
static
function
fixNulls
(
array
$array
,
$count
)
{
if
(
count
(
$array
)
===
$count
)
{
return
$array
;
}
else
{
return
array_replace
(
array_fill
(
1
,
$count
,
null
),
$array
);
}
}
/**
* Handle a protocol 'call' message from Lua
* @param array $message
* @return array Response message to send to Lua
*/
protected
function
handleCall
(
$message
)
{
$message
[
'args'
]
=
self
::
fixNulls
(
$message
[
'args'
],
$message
[
'nargs'
]
);
try
{
$result
=
$this
->
callback
(
$message
[
'id'
],
$message
[
'args'
]
);
}
catch
(
LuaError
$e
)
{
return
[
'op'
=>
'error'
,
'value'
=>
$e
->
getLuaMessage
(),
];
}
// Convert to a 1-based array
if
(
$result
!==
null
&&
count
(
$result
)
)
{
$result
=
array_combine
(
range
(
1
,
count
(
$result
)
),
$result
);
}
else
{
$result
=
[];
}
return
[
'op'
=>
'return'
,
'nvalues'
=>
count
(
$result
),
'values'
=>
$result
];
}
/**
* Call a registered/wrapped PHP function from Lua
* @param string $id Callback ID
* @param array $args Arguments to pass to the callback
* @return mixed Return value from the callback
*/
protected
function
callback
(
$id
,
array
$args
)
{
return
(
$this
->
callbacks
[
$id
]
)(
...
$args
);
}
/**
* Handle a protocol error response
*
* Converts the encoded Lua error to an appropriate exception and throws it.
*
* @param array $message
* @return never
*/
protected
function
handleError
(
$message
)
{
$opts
=
[];
$message
[
'value'
]
=
Validator
::
cleanUp
(
$message
[
'value'
]
);
if
(
preg_match
(
'/^(.*?):(
\d
+): (.*)$/'
,
$message
[
'value'
],
$m
)
)
{
$opts
[
'module'
]
=
$m
[
1
];
$opts
[
'line'
]
=
$m
[
2
];
$message
[
'value'
]
=
$m
[
3
];
}
if
(
isset
(
$message
[
'trace'
]
)
)
{
foreach
(
$message
[
'trace'
]
as
&
$val
)
{
$val
=
array_map
(
static
function
(
$val
)
{
if
(
is_string
(
$val
)
)
{
$val
=
Validator
::
cleanUp
(
$val
);
}
return
$val
;
},
$val
);
}
$opts
[
'trace'
]
=
array_values
(
$message
[
'trace'
]
);
}
throw
$this
->
engine
->
newLuaError
(
$message
[
'value'
],
$opts
);
}
/**
* Send a protocol message to Lua, and handle any responses
* @param array $msgToLua
* @return mixed Response data
*/
protected
function
dispatch
(
$msgToLua
)
{
$this
->
sendMessage
(
$msgToLua
);
while
(
true
)
{
$msgFromLua
=
$this
->
receiveMessage
();
switch
(
$msgFromLua
[
'op'
]
)
{
case
'return'
:
return
self
::
fixNulls
(
$msgFromLua
[
'values'
],
$msgFromLua
[
'nvalues'
]
);
case
'call'
:
$msgToLua
=
$this
->
handleCall
(
$msgFromLua
);
$this
->
sendMessage
(
$msgToLua
);
break
;
case
'error'
:
$this
->
handleError
(
$msgFromLua
);
// handleError prevents continuation
default
:
$this
->
logger
->
error
(
__METHOD__
.
": invalid response op
\"
{$msgFromLua['op']}
\"
"
);
throw
$this
->
engine
->
newException
(
'scribunto-luastandalone-decode-error'
);
}
}
}
/**
* Send a protocol message to Lua
* @param array $msg
*/
protected
function
sendMessage
(
$msg
)
{
$this
->
debug
(
"TX ==> {$msg['op']}"
);
$this
->
checkValid
();
// Send the message
$encMsg
=
$this
->
encodeMessage
(
$msg
);
if
(
!
fwrite
(
$this
->
writePipe
,
$encMsg
)
)
{
// Write error, probably the process has terminated
// If it has, handleIOError() will throw. If not, throw an exception ourselves.
$this
->
handleIOError
();
throw
$this
->
engine
->
newException
(
'scribunto-luastandalone-write-error'
);
}
}
/**
* Receive a protocol message from Lua
* @return array
*/
protected
function
receiveMessage
()
{
$this
->
checkValid
();
// Read the header
$header
=
fread
(
$this
->
readPipe
,
16
);
if
(
strlen
(
$header
)
!==
16
)
{
$this
->
handleIOError
();
throw
$this
->
engine
->
newException
(
'scribunto-luastandalone-read-error'
);
}
$length
=
$this
->
decodeHeader
(
$header
);
// Read the reply body
$body
=
''
;
$lengthRemaining
=
$length
;
while
(
$lengthRemaining
)
{
$buffer
=
fread
(
$this
->
readPipe
,
$lengthRemaining
);
if
(
$buffer
===
false
||
feof
(
$this
->
readPipe
)
)
{
$this
->
handleIOError
();
throw
$this
->
engine
->
newException
(
'scribunto-luastandalone-read-error'
);
}
$body
.=
$buffer
;
$lengthRemaining
-=
strlen
(
$buffer
);
}
$body
=
strtr
(
$body
,
[
'
\\
r'
=>
"
\r
"
,
'
\\
n'
=>
"
\n
"
,
'
\\\\
'
=>
'
\\
'
,
]
);
$msg
=
unserialize
(
$body
);
$this
->
debug
(
"RX <== {$msg['op']}"
);
return
$msg
;
}
/**
* Encode a protocol message to send to Lua
* @param mixed $message
* @return string
*/
protected
function
encodeMessage
(
$message
)
{
$serialized
=
$this
->
encodeLuaVar
(
$message
);
$length
=
strlen
(
$serialized
);
$check
=
$length
*
2
-
1
;
return
sprintf
(
'%08x%08x%s'
,
$length
,
$check
,
$serialized
);
}
/**
* @param mixed $var
* @param int $level
*
* @return string
*/
protected
function
encodeLuaVar
(
$var
,
$level
=
0
)
{
if
(
$level
>
100
)
{
throw
new
RuntimeException
(
__METHOD__
.
': recursion depth limit exceeded'
);
}
$type
=
gettype
(
$var
);
switch
(
$type
)
{
case
'boolean'
:
return
$var
?
'true'
:
'false'
;
case
'integer'
:
return
$var
;
case
'double'
:
if
(
!
is_finite
(
$var
)
)
{
if
(
is_nan
(
$var
)
)
{
return
'(0/0)'
;
}
if
(
$var
===
INF
)
{
return
'(1/0)'
;
}
if
(
$var
===
-
INF
)
{
return
'(-1/0)'
;
}
throw
new
InvalidArgumentException
(
__METHOD__
.
': cannot convert non-finite number'
);
}
return
sprintf
(
'%.17g'
,
$var
);
case
'string'
:
return
'"'
.
strtr
(
$var
,
[
'"'
=>
'
\\
"'
,
'
\\
'
=>
'
\\\\
'
,
"
\n
"
=>
'
\\
n'
,
"
\r
"
=>
'
\\
r'
,
"
\0
00"
=>
'
\\
000'
,
]
)
.
'"'
;
case
'array'
:
$s
=
'{'
;
foreach
(
$var
as
$key
=>
$element
)
{
if
(
$s
!==
'{'
)
{
$s
.=
','
;
}
// Lua's number type can't represent most integers beyond 2**53, so stringify such keys
if
(
is_int
(
$key
)
&&
(
$key
>
9007199254740992
||
$key
<
-
9007199254740992
)
)
{
$key
=
(
string
)
$key
;
}
$s
.=
'['
.
$this
->
encodeLuaVar
(
$key
,
$level
+
1
)
.
']'
.
'='
.
$this
->
encodeLuaVar
(
$element
,
$level
+
1
);
}
$s
.=
'}'
;
return
$s
;
case
'object'
:
if
(
!(
$var
instanceof
LuaStandaloneInterpreterFunction
)
)
{
throw
new
InvalidArgumentException
(
__METHOD__
.
': unable to convert object of type '
.
get_class
(
$var
)
);
}
elseif
(
$var
->
interpreterId
!==
$this
->
id
)
{
throw
new
InvalidArgumentException
(
__METHOD__
.
': unable to convert function belonging to a different interpreter'
);
}
else
{
return
'chunks['
.
intval
(
$var
->
id
)
.
']'
;
}
case
'resource'
:
throw
new
InvalidArgumentException
(
__METHOD__
.
': unable to convert resource'
);
case
'NULL'
:
return
'nil'
;
default
:
throw
new
InvalidArgumentException
(
__METHOD__
.
': unable to convert variable of unknown type'
);
}
}
/**
* Verify protocol header and extract the body length.
* @param string $header
* @return int Length
*/
protected
function
decodeHeader
(
$header
)
{
$length
=
substr
(
$header
,
0
,
8
);
$check
=
substr
(
$header
,
8
,
8
);
if
(
!
preg_match
(
'/^[0-9a-f]+$/'
,
$length
)
||
!
preg_match
(
'/^[0-9a-f]+$/'
,
$check
)
)
{
throw
$this
->
engine
->
newException
(
'scribunto-luastandalone-decode-error'
);
}
$length
=
hexdec
(
$length
);
$check
=
hexdec
(
$check
);
if
(
$length
*
2
-
1
!==
$check
)
{
throw
$this
->
engine
->
newException
(
'scribunto-luastandalone-decode-error'
);
}
return
$length
;
}
/**
* @throws ScribuntoException
*/
protected
function
checkValid
()
{
if
(
!
$this
->
proc
)
{
$this
->
logger
->
error
(
__METHOD__
.
": process already terminated"
);
if
(
$this
->
exitError
)
{
throw
$this
->
exitError
;
}
else
{
throw
$this
->
engine
->
newException
(
'scribunto-luastandalone-gone'
);
}
}
}
/**
* @throws ScribuntoException
*/
protected
function
handleIOError
()
{
$this
->
checkValid
();
// Terminate, fetch the status, then close. proc_close()'s return
// value isn't helpful here because there's no way to differentiate a
// signal-kill from a normal exit.
proc_terminate
(
$this
->
proc
);
while
(
true
)
{
$status
=
proc_get_status
(
$this
->
proc
);
// XXX: Should proc_get_status docs be changed so that
// its documented as possibly returning false?
'@phan-var array|false $status'
;
if
(
$status
===
false
)
{
// WTF? Let the caller throw an appropriate error.
return
;
}
if
(
!
$status
[
'running'
]
)
{
break
;
}
// Give the killed process a chance to be scheduled
usleep
(
10000
);
}
proc_close
(
$this
->
proc
);
$this
->
proc
=
false
;
// proc_open() sometimes uses a shell, check for shell-style signal reporting.
if
(
!
$status
[
'signaled'
]
&&
(
$status
[
'exitcode'
]
&
0x80
)
===
0x80
)
{
$status
[
'signaled'
]
=
true
;
$status
[
'termsig'
]
=
$status
[
'exitcode'
]
-
128
;
}
if
(
$status
[
'signaled'
]
)
{
if
(
defined
(
'SIGXCPU'
)
&&
$status
[
'termsig'
]
===
SIGXCPU
)
{
$this
->
exitError
=
$this
->
engine
->
newException
(
'scribunto-common-timeout'
);
}
else
{
$this
->
exitError
=
$this
->
engine
->
newException
(
'scribunto-luastandalone-signal'
,
[
'args'
=>
[
$status
[
'termsig'
]
]
]
);
}
}
else
{
$this
->
exitError
=
$this
->
engine
->
newException
(
'scribunto-luastandalone-exited'
,
[
'args'
=>
[
$status
[
'exitcode'
]
]
]
);
}
throw
$this
->
exitError
;
}
/**
* @param string $msg
*/
protected
function
debug
(
$msg
)
{
if
(
$this
->
enableDebug
)
{
$this
->
logger
->
debug
(
"Lua: $msg"
);
}
}
}
File Metadata
Details
Attached
Mime Type
text/x-php
Expires
Aug 15 2026, 12:08 (12 w, 4 d ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
8c/63/70f185b9e10ecd9cf89e92bd1b3f
Default Alt Text
LuaStandaloneInterpreter.php (18 KB)
Attached To
Mode
rMWPROD MediaWiki Production
Attached
Detach File
Event Timeline
Log In to Comment