summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorraul2014-07-11 15:14:50 +0200
committerraul2014-07-11 15:14:50 +0200
commit0f802832400d75a81984d41152137cee119d836a (patch)
tree42cf503b23795a6689af5544c48e10b05a668197
parent[i18n] deleted old unnecessary files (diff)
parent[i18n] deleted old unnecessary files (diff)
downloadslx-admin-0f802832400d75a81984d41152137cee119d836a.tar.gz
slx-admin-0f802832400d75a81984d41152137cee119d836a.tar.xz
slx-admin-0f802832400d75a81984d41152137cee119d836a.zip
XMerge branch 'i18n' of gitlab.c3sl.ufpr.br:cdn/slx-admin into i18n
-rw-r--r--.gitignore1
-rw-r--r--apis/getconfig.inc.php14
-rw-r--r--apis/init.inc.php2
-rw-r--r--apis/taskmanager.inc.php6
-rwxr-xr-xexternal/build_ipxe.sh40
-rw-r--r--external/tgz/list.php24
-rw-r--r--inc/configmodule.inc.php36
-rw-r--r--inc/download.inc.php93
-rw-r--r--inc/property.inc.php20
-rw-r--r--inc/render.inc.php1
-rw-r--r--inc/taskmanager.inc.php65
-rw-r--r--inc/trigger.inc.php99
-rw-r--r--inc/util.inc.php137
-rw-r--r--index.php2
-rw-r--r--modules/main.inc.php2
-rw-r--r--modules/news.inc.php70
-rw-r--r--modules/serversetup.inc.php4
-rw-r--r--modules/sysconfig.inc.php4
-rw-r--r--modules/sysconfig/addconfig.inc.php3
-rw-r--r--modules/sysconfig/addmodule_branding.inc.php213
-rw-r--r--templates/serversetup/ipaddress.html2
-rw-r--r--templates/serversetup/ipxe.html5
-rw-r--r--templates/sysconfig/_page.html (renamed from templates/page-sysconfig-main.html)0
-rw-r--r--templates/sysconfig/branding-check.html29
-rw-r--r--templates/sysconfig/branding-start.html19
25 files changed, 664 insertions, 227 deletions
diff --git a/.gitignore b/.gitignore
index b351b9d1..93b46646 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@
*.bak
*.tmp
*.swp
+nbproject/
diff --git a/apis/getconfig.inc.php b/apis/getconfig.inc.php
index 0a8db6be..84393dbc 100644
--- a/apis/getconfig.inc.php
+++ b/apis/getconfig.inc.php
@@ -1,8 +1,15 @@
<?php
+/**
+ * Escape given string so it is a valid string in sh that can be surrounded
+ * by single quotes ('). This basically turns _'_ into _'"'"'_
+ *
+ * @param string $string input
+ * @return string escaped sh string
+ */
function escape($string)
{
- return str_replace("'", "\\'", $string);
+ return str_replace("'", "'\"'\"'", $string);
}
// Dump config from DB
@@ -12,12 +19,15 @@ $res = Database::simpleQuery('SELECT setting.setting, setting.defaultvalue, sett
ORDER BY setting ASC'); // TODO: Add setting groups and sort order
while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
if (is_null($row['value'])) $row['value'] = $row['defaultvalue'];
- echo $row['setting'] . "='" . str_replace("'", "'\"'\"'", $row['value']) . "'\n";
+ echo $row['setting'] . "='" . escape($row['value']) . "'\n";
}
+
// Additional "intelligent" config
// Remote log URL
echo "SLX_REMOTE_LOG='http://" . escape($_SERVER['SERVER_ADDR']) . "/slxadmin/api.php?do=clientlog'\n";
+// vm list url
+echo "SLX_VMCHOOSER_BASE_URL='http://" . escape($_SERVER['SERVER_ADDR']) . "/vmchooser/'\n";
// VMStore path and type
$vmstore = Property::getVmStoreConfig();
diff --git a/apis/init.inc.php b/apis/init.inc.php
index cdd6ac05..d24b2cca 100644
--- a/apis/init.inc.php
+++ b/apis/init.inc.php
@@ -5,3 +5,5 @@ if ($_SERVER['REMOTE_ADDR'] !== '127.0.0.1')
Trigger::ldadp();
Trigger::mount();
+Trigger::autoUpdateServerIp();
+Trigger::ipxe();
diff --git a/apis/taskmanager.inc.php b/apis/taskmanager.inc.php
index f7ee6ac1..8365aac7 100644
--- a/apis/taskmanager.inc.php
+++ b/apis/taskmanager.inc.php
@@ -15,12 +15,6 @@ foreach ($_POST['ids'] as $id) {
continue;
}
$return[] = $status;
- // HACK HACK - should be pluggable
- if (isset($status['statusCode']) && $status['statusCode'] === TASK_FINISHED // iPXE Update
- && $id === Property::getIPxeTaskId() && Property::getServerIp() !== Property::getIPxeIp()) {
- Property::setIPxeIp(Property::getServerIp());
- }
- // -- END HACKS --
if (!isset($status['statusCode']) || ($status['statusCode'] !== TASK_WAITING && $status['statusCode'] !== TASK_PROCESSING)) {
Taskmanager::release($id);
}
diff --git a/external/build_ipxe.sh b/external/build_ipxe.sh
deleted file mode 100755
index 8cb23cd0..00000000
--- a/external/build_ipxe.sh
+++ /dev/null
@@ -1,40 +0,0 @@
-#!/bin/bash
-
-# Call: $0 <ip_file> <server_ip> <logfile>
-# Self-Call: $0 --exec <ip_file> <server_ip>
-
-if [ $# -lt 3 ]; then
- echo "Falscher Aufruf: Keine zwei Parameter angegeben!"
- exit 1
-fi
-
-if [ "$1" != "--exec" ]; then
- $0 --exec "$1" "$2" > "$3" 2>&1 &
- RET=$!
- echo "PID: ${RET}."
- exit 0
-fi
-
-FILE="$2"
-SERVER="$3"
-
-cd "/opt/openslx/ipxe/src"
-
-[ -e "bin/undionly.kkkpxe" ] && unlink "bin/undionly.kkkpxe"
-
-make bin/undionly.kkkpxe EMBED=../ipxelinux.ipxe,../pxelinux.0
-
-if [ ! -e "bin/undionly.kkkpxe" -o "$(stat -c %s "bin/undionly.kkkpxe")" -lt 80000 ]; then
- echo "Error compiling ipxelinux.0"
- exit 1
-fi
-
-if ! cp "bin/undionly.kkkpxe" "/srv/openslx/tftp/ipxelinux.0"; then
- echo "** Error copying ipxelinux.0 to target **"
- exit 1
-fi
-
-echo -n "$SERVER" > "$FILE"
-echo " ** SUCCESS **"
-exit 0
-
diff --git a/external/tgz/list.php b/external/tgz/list.php
deleted file mode 100644
index 5c8d1c67..00000000
--- a/external/tgz/list.php
+++ /dev/null
@@ -1,24 +0,0 @@
-<?php
-
-/*
-echo '[';
-
-$first = true;
-foreach (glob('./*.tgz') as $file) {
- if (!$first) echo ', ';
- $first = false;
- echo ' { "file" : "' . basename($file) . '", "description" : "<Beschreibung>" }';
-}
-echo ' ]';
-*/
-
-$files = array();
-foreach (glob('./*.tgz') as $file) {
- $files[] = array(
- 'file' => basename($file),
- 'description' => 'Eine sinnvolle Beschreibung'
- );
-}
-
-echo json_encode($files);
-
diff --git a/inc/configmodule.inc.php b/inc/configmodule.inc.php
index e9fa40bb..c0838b5c 100644
--- a/inc/configmodule.inc.php
+++ b/inc/configmodule.inc.php
@@ -36,16 +36,44 @@ class ConfigModule
);
$data = json_encode($ownEntry);
if ($data === false) Util::traceError('Serializing the AD data failed.');
- $name = CONFIG_TGZ_LIST_DIR . '/modules/AD_AUTH_id_' . $id . '.' . mt_rand() . '.tgz';
+ $moduleTgz = CONFIG_TGZ_LIST_DIR . '/modules/AD_AUTH_id_' . $id . '.' . mt_rand() . '.tgz';
Database::exec("UPDATE configtgz_module SET filepath = :filename, contents = :contents WHERE moduleid = :id LIMIT 1", array(
'id' => $id,
- 'filename' => $name,
- 'contents' => json_encode($ownEntry)
+ 'filename' => $moduleTgz,
+ 'contents' => $data
));
// Add archive file name to array before returning it
$ownEntry['moduleid'] = $id;
- $ownEntry['filename'] = $name;
+ $ownEntry['filename'] = $moduleTgz;
return $ownEntry;
}
+ public static function insertBrandingModule($title, $archive)
+ {
+ Database::exec("INSERT INTO configtgz_module (title, moduletype, filepath, contents) "
+ . " VALUES (:title, 'BRANDING', '', '')", array('title' => $title));
+ $id = Database::lastInsertId();
+ if (!is_numeric($id)) Util::traceError('Inserting new Branding Module into DB did not yield a numeric insert id');
+ // Move tgz
+ $moduleTgz = CONFIG_TGZ_LIST_DIR . '/modules/BRANDING_id_' . $id . '.' . mt_rand() . '.tgz';
+ $task = Taskmanager::submit('MoveFile', array(
+ 'source' => $archive,
+ 'destination' => $moduleTgz
+ ));
+ $task = Taskmanager::waitComplete($task, 3000);
+ if (Taskmanager::isFailed($task) || $task['statusCode'] !== TASK_FINISHED) {
+ Taskmanager::addErrorMessage($task);
+ Database::exec("DELETE FROM configtgz_module WHERE moduleid = :moduleid LIMIT 1", array(
+ 'moduleid' => $id
+ ));
+ return false;
+ }
+ // Update with path
+ Database::exec("UPDATE configtgz_module SET filepath = :filename WHERE moduleid = :id LIMIT 1", array(
+ 'id' => $id,
+ 'filename' => $moduleTgz
+ ));
+ return true;
+ }
+
}
diff --git a/inc/download.inc.php b/inc/download.inc.php
new file mode 100644
index 00000000..6485ee24
--- /dev/null
+++ b/inc/download.inc.php
@@ -0,0 +1,93 @@
+<?php
+
+class Download
+{
+
+ /**
+ * Common initialization for download and downloadToFile
+ * Return file handle to header file
+ */
+ private static function initCurl($url, $timeout, &$head)
+ {
+ $ch = curl_init();
+ if ($ch === false)
+ Util::traceError('Could not initialize cURL');
+ curl_setopt($ch, CURLOPT_URL, $url);
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, ceil($timeout / 2));
+ curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
+ curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
+ curl_setopt($ch, CURLOPT_AUTOREFERER, true);
+ curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
+ curl_setopt($ch, CURLOPT_MAXREDIRS, 6);
+ $tmpfile = tempnam('/tmp/', 'bwlp-');
+ $head = fopen($tmpfile, 'w+b');
+ if ($head === false)
+ Util::traceError("Could not open temporary head file $tmpfile for writing.");
+ curl_setopt($ch, CURLOPT_WRITEHEADER, $head);
+ return $ch;
+ }
+
+ /**
+ * Read 10kb from the given file handle, seek to 0 first,
+ * close the file after reading. Returns data read
+ */
+ private static function getContents($fh)
+ {
+ fseek($fh, 0, SEEK_SET);
+ $data = fread($fh, 10000);
+ fclose($fh);
+ return $data;
+ }
+
+ /**
+ * Download file, obey given timeout in seconds
+ * Return data on success, false on failure
+ */
+ public static function asString($url, $timeout, &$code)
+ {
+ $ch = self::initCurl($url, $timeout, $head);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ $data = curl_exec($ch);
+ $head = self::getContents($head);
+ if (preg_match('#^HTTP/\d+\.\d+ (\d+) #', $head, $out)) {
+ $code = (int) $out[1];
+ } else {
+ $code = 999;
+ }
+ curl_close($ch);
+ return $data;
+ }
+
+ /**
+ * Download a file from a URL to file.
+ *
+ * @param string $target destination path to download file to
+ * @param string $url URL of file to download
+ * @param int $timeout timeout in seconds
+ * @param int $code HTTP status code passed out by reference
+ * @return boolean
+ */
+ public static function toFile($target, $url, $timeout, &$code)
+ {
+ $fh = fopen($target, 'wb');
+ if ($fh === false)
+ Util::traceError("Could not open $target for writing.");
+ $ch = self::initCurl($url, $timeout, $head);
+ curl_setopt($ch, CURLOPT_FILE, $fh);
+ $res = curl_exec($ch);
+ $head = self::getContents($head);
+ curl_close($ch);
+ fclose($fh);
+ if ($res === false) {
+ @unlink($target);
+ return false;
+ }
+ if (preg_match_all('#\bHTTP/\d+\.\d+ (\d+) #', $head, $out, PREG_SET_ORDER)) {
+ $code = (int) $out[count($out)-1][1];
+ } else {
+ $code = '999 ' . curl_error($ch);
+ }
+ return true;
+ }
+
+}
diff --git a/inc/property.inc.php b/inc/property.inc.php
index 00c8018f..81de137f 100644
--- a/inc/property.inc.php
+++ b/inc/property.inc.php
@@ -62,26 +62,6 @@ class Property
self::set('server-ip', $value);
}
- public static function getIPxeIp()
- {
- return self::get('ipxe-ip', 'not-set');
- }
-
- public static function setIPxeIp($value)
- {
- self::set('ipxe-ip', $value);
- }
-
- public static function getIPxeTaskId()
- {
- return self::get('ipxe-task');
- }
-
- public static function setIPxeTaskId($value)
- {
- self::set('ipxe-task', $value);
- }
-
public static function getBootMenu()
{
return json_decode(self::get('ipxe-menu'), true);
diff --git a/inc/render.inc.php b/inc/render.inc.php
index 1a34c45c..0b891c80 100644
--- a/inc/render.inc.php
+++ b/inc/render.inc.php
@@ -65,7 +65,6 @@ class Render
' </div>
<script src="script/jquery.js"></script>
<script src="script/bootstrap.min.js"></script>
- <script src="script/bootstrap-tagsinput.min.js"></script>
<script src="script/taskmanager.js"></script>
',
self::$footer
diff --git a/inc/taskmanager.inc.php b/inc/taskmanager.inc.php
index 3862ac72..5813164a 100644
--- a/inc/taskmanager.inc.php
+++ b/inc/taskmanager.inc.php
@@ -6,6 +6,10 @@
class Taskmanager
{
+ /**
+ * UDP socket used for communication with the task manager
+ * @var resource
+ */
private static $sock = false;
private static function init()
@@ -18,6 +22,15 @@ class Taskmanager
socket_connect(self::$sock, '127.0.0.1', 9215);
}
+ /**
+ * Start a task via the task manager.
+ *
+ * @param string $task name of task to start
+ * @param array $data data to pass to the task. the structure depends on the task.
+ * @param boolean $async if true, the function will not wait for the reply of the taskmanager, which means
+ * the return value is just true (and you won't know if the task could acutally be started)
+ * @return array struct representing the task status, or result of submit, false on communication error
+ */
public static function submit($task, $data = false, $async = false)
{
self::init();
@@ -44,11 +57,22 @@ class Taskmanager
return $reply;
}
- public static function status($taskId)
+ /**
+ * Query status of given task.
+ *
+ * @param mixed $task task id or task struct
+ * @return array status of task as array, or false on communication error
+ */
+ public static function status($task)
{
+ if (is_array($task) && isset($task['id'])) {
+ $task = $task['id'];
+ }
+ if (!is_string($task))
+ return false;
self::init();
$seq = (string) mt_rand();
- $message = "$seq, status, $taskId";
+ $message = "$seq, status, $task";
$sent = socket_send(self::$sock, $message, strlen($message), 0);
$reply = self::readReply($seq);
if (!is_array($reply))
@@ -56,6 +80,13 @@ class Taskmanager
return $reply;
}
+ /**
+ * Wait for the given task's completion.
+ *
+ * @param type $task task to wait for
+ * @param int $timeout maximum time in ms to wait for completion of task
+ * @return array result/status of task, or false if it couldn't be queried
+ */
public static function waitComplete($task, $timeout = 1500)
{
if (is_array($task) && isset($task['id'])) {
@@ -76,13 +107,19 @@ class Taskmanager
$done = true;
break;
}
- usleep(150000);
+ usleep(100000);
}
if ($done)
self::release($task);
return $status;
}
+ /**
+ * Check whether the given task can be considered failed.
+ *
+ * @param mixed $task task id or struct representing task
+ * @return boolean true if task failed, false if finished successfully or still waiting/running
+ */
public static function isFailed($task)
{
if (!is_array($task) || !isset($task['statusCode']) || !isset($task['id']))
@@ -113,23 +150,35 @@ class Taskmanager
Message::addError('task-error', $task['statusCode']);
}
- public static function release($taskId)
+ /**
+ * Release a given task from the task manager, so it won't keep the result anymore in case it's finished running.
+ *
+ * @param string $task task to release. can either be its id, or a struct representing the task, as returned
+ * by ::submit() or ::status()
+ */
+ public static function release($task)
{
+ if (is_array($task) && isset($task['id'])) {
+ $task = $task['id'];
+ }
+ if (!is_string($task))
+ return;
self::init();
$seq = (string) mt_rand();
- $message = "$seq, release, $taskId";
+ $message = "$seq, release, $task";
socket_send(self::$sock, $message, strlen($message), 0);
}
/**
- *
- * @param type $seq
+ * Read reply from socket for given sequence number.
+ *
+ * @param string $seq
* @return mixed the decoded json data for that message as an array, or null on error
*/
private static function readReply($seq)
{
$tries = 0;
- while (($bytes = socket_recvfrom(self::$sock, $buf, 90000, 0, $bla1, $bla2)) !== false) {
+ while (($bytes = socket_recvfrom(self::$sock, $buf, 90000, 0, $bla1, $bla2)) !== false || socket_last_error() === 11) {
$parts = explode(',', $buf, 2);
if (count($parts) == 2 && $parts[0] == $seq) {
return json_decode($parts[1], true);
diff --git a/inc/trigger.inc.php b/inc/trigger.inc.php
index 2fde45ab..e6f7cd31 100644
--- a/inc/trigger.inc.php
+++ b/inc/trigger.inc.php
@@ -18,30 +18,66 @@ class Trigger
* @param boolean $force force recompilation even if it seems up to date
* @return boolean|string true if already up to date, false if launching task failed, task-id otherwise
*/
- public static function ipxe($force = false)
+ public static function ipxe()
{
- if (!$force && Property::getIPxeIp() === Property::getServerIp())
- return true; // Nothing to do
- $last = Property::getIPxeTaskId();
- if ($last !== false) {
- $status = Taskmanager::status($last);
- if (isset($status['statusCode']) && ($status['statusCode'] === TASK_WAITING || $status['statusCode'] === TASK_PROCESSING))
- return false; // Already compiling
- }
$data = Property::getBootMenu();
- $data['ip'] = Property::getServerIp();
$task = Taskmanager::submit('CompileIPxe', $data);
if (!isset($task['id']))
return false;
- Property::setIPxeTaskId($task['id']);
return $task['id'];
}
/**
- *
+ * Try to automatically determine the primary IP address of the server.
+ * This only works if the server has either one public IPv4 address (and potentially
+ * one or more non-public addresses), or one private address.
+ */
+ public static function autoUpdateServerIp()
+ {
+ $task = Taskmanager::submit('LocalAddressesList');
+ if ($task === false)
+ return;
+ $task = Taskmanager::waitComplete($task, 10000);
+ if (!isset($task['data']['addresses']) || empty($task['data']['addresses']))
+ return;
+
+ $serverIp = Property::getServerIp();
+ $publicCandidate = 'none';
+ $privateCandidate = 'none';
+ foreach ($task['data']['addresses'] as $addr) {
+ if ($addr['ip'] === $serverIp)
+ return;
+ if (substr($addr['ip'], 0, 4) === '127.')
+ continue;
+ if (Util::isPublicIpv4($addr['ip'])) {
+ if ($publicCandidate === 'none')
+ $publicCandidate = $addr['ip'];
+ else
+ $publicCandidate = 'many';
+ } else {
+ if ($privateCandidate === 'none')
+ $privateCandidate = $addr['ip'];
+ else
+ $privateCandidate = 'many';
+ }
+ }
+ if ($publicCandidate !== 'none' && $publicCandidate !== 'many') {
+ Property::setServerIp($publicCandidate);
+ return;
+ }
+ if ($privateCandidate !== 'none' && $privateCandidate !== 'many') {
+ Property::setServerIp($privateCandidate);
+ return;
+ }
+ }
+
+ /**
+ * Launch all ldadp instances that need to be running.
+ *
+ * @param string $parent if not NULL, this will be the parent task of the launch-task
* @return boolean|string false on error, id of task otherwise
*/
- public static function ldadp()
+ public static function ldadp($parent = NULL)
{
$res = Database::simpleQuery("SELECT moduleid, configtgz.filepath FROM configtgz_module"
. " INNER JOIN configtgz_x_module USING (moduleid)"
@@ -56,17 +92,50 @@ class Trigger
}
}
$task = Taskmanager::submit('LdadpLauncher', array(
- 'ids' => $id
+ 'ids' => $id,
+ 'parentTask' => $parent,
+ 'failOnParentFail' => false
));
if (!isset($task['id']))
return false;
return $task['id'];
}
+ /**
+ * To be called if the server ip changes, as it's embedded in the AD module configs.
+ * This will then recreate all AD tgz modules.
+ */
+ public static function rebuildAdModules()
+ {
+ $res = Database::simpleQuery("SELECT moduleid, filepath, content FROM configtgz_module"
+ . " WHERE moduletype = 'AD_AUTH'");
+ if ($res->rowCount() === 0)
+ return;
+
+ $task = Taskmanager::submit('LdadpLauncher', array('ids' => array())); // Stop all running instances
+ $parent = isset($task['id']) ? $task['id'] : NULL;
+ while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ $config = json_decode($row['contents']);
+ $config['proxyip'] = Property::getServerIp();
+ $config['moduleid'] = $row['moduleid'];
+ $config['filename'] = $row['filepath'];
+ $config['parentTask'] = $parent;
+ $config['failOnParentFail'] = false;
+ $task = Taskmanager::submit('CreateAdConfig', $config);
+ $parent = isset($task['id']) ? $task['id'] : NULL;
+ }
+
+ }
+
+ /**
+ * Mount the VM store into the server.
+ *
+ * @return array task status of mount procedure, or false on error
+ */
public static function mount()
{
$vmstore = Property::getVmStoreConfig();
- if (!is_array($vmstore)) return;
+ if (!is_array($vmstore)) return false;
$storetype = $vmstore['storetype'];
if ($storetype === 'nfs') $addr = $vmstore['nfsaddr'];
if ($storetype === 'cifs') $addr = $vmstore['cifsaddr'];
diff --git a/inc/util.inc.php b/inc/util.inc.php
index 8b5a14e4..45a6b684 100644
--- a/inc/util.inc.php
+++ b/inc/util.inc.php
@@ -54,8 +54,10 @@ class Util
*/
public static function verifyToken()
{
- if (Session::get('token') === false) return true;
- if (isset($_REQUEST['token']) && Session::get('token') === $_REQUEST['token']) return true;
+ if (Session::get('token') === false)
+ return true;
+ if (isset($_REQUEST['token']) && Session::get('token') === $_REQUEST['token'])
+ return true;
Message::addError('token');
return false;
}
@@ -77,85 +79,6 @@ class Util
}
/**
- * Common initialization for download and downloadToFile
- * Return file handle to header file
- */
- private static function initCurl($url, $timeout, &$head)
- {
- $ch = curl_init();
- if ($ch === false) Util::traceError('Could not initialize cURL');
- curl_setopt($ch, CURLOPT_URL, $url);
- curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, ceil($timeout / 2));
- curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
- curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
- curl_setopt($ch, CURLOPT_AUTOREFERER, true);
- curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
- curl_setopt($ch, CURLOPT_MAXREDIRS, 6);
- $tmpfile = '/tmp/' . mt_rand() . '-' . time();
- $head = fopen($tmpfile, 'w+b');
- if ($head === false) Util::traceError("Could not open temporary head file $tmpfile for writing.");
- curl_setopt($ch, CURLOPT_WRITEHEADER, $head);
- return $ch;
- }
-
- /**
- * Read 10kb from the given file handle, seek to 0 first,
- * close the file after reading. Returns data read
- */
- private static function getContents($fh)
- {
- fseek($fh, 0, SEEK_SET);
- $data = fread($fh, 10000);
- fclose($fh);
- return $data;
- }
-
- /**
- * Download file, obey given timeout in seconds
- * Return data on success, false on failure
- */
- public static function download($url, $timeout, &$code)
- {
- $ch = self::initCurl($url, $timeout, $head);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- $data = curl_exec($ch);
- $head = self::getContents($head);
- if (preg_match('#^HTTP/\d+\.\d+ (\d+) #', $head, $out)) {
- $code = (int)$out[1];
- } else {
- $code = 999;
- }
- curl_close($ch);
- return $data;
- }
-
- /**
- * Download file, obey given timeout in seconds
- * Return true on success, false on failure
- */
- public static function downloadToFile($target, $url, $timeout, &$code)
- {
- $fh = fopen($target, 'wb');
- if ($fh === false) Util::traceError("Could not open $target for writing.");
- $ch = self::initCurl($url, $timeout, $head);
- curl_setopt($ch, CURLOPT_FILE, $fh);
- $res = curl_exec($ch);
- $head = self::getContents($head);
- curl_close($ch);
- fclose($fh);
- if ($res === false) {
- @unlink($target);
- return false;
- }
- if (preg_match('#^HTTP/\d+\.\d+ (\d+) #', $head, $out)) {
- $code = (int)$out[1];
- } else {
- $code = '999 ' . curl_error($ch);
- }
- return true;
- }
-
- /**
* Convert given number to human readable file size string.
* Will append Bytes, KiB, etc. depending on magnitude of number.
*
@@ -163,7 +86,8 @@ class Util
* @param type $decimals number of decimals to show, -1 for automatic
* @return type human readable string representing the given filesize
*/
- public static function readableFileSize($bytes, $decimals = -1) {
+ public static function readableFileSize($bytes, $decimals = -1)
+ {
static $sz = array('Byte', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB');
$factor = floor((strlen($bytes) - 1) / 3);
if ($factor == 0) {
@@ -173,12 +97,12 @@ class Util
}
return sprintf("%.{$decimals}f ", $bytes / pow(1024, $factor)) . $sz[$factor];
}
-
+
public static function sanitizeFilename($name)
{
return preg_replace('/[^a-zA-Z0-9_\-]+/', '_', $name);
}
-
+
/**
* Create human readable error description from a $_FILES[<..>]['error'] code
*
@@ -217,5 +141,48 @@ class Util
return $message;
}
-}
+ /**
+ * Is given string a public ipv4 address?
+ *
+ * @param string $ip_addr input to check
+ * @return boolean true iff $ip_addr is a valid public ipv4 address
+ */
+ public static function isPublicIpv4($ip_addr)
+ {
+ if (!preg_match("/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/", $ip_addr))
+ return false;
+
+ $parts = explode(".", $ip_addr);
+ foreach ($parts as $part) {
+ if (!is_numeric($part) || $part > 255 || $part < 0)
+ return false;
+ }
+ if ($parts[0] == 0 || $parts[0] == 10 || $parts[0] == 127 || ($parts[0] > 223 && $parts[0] < 240))
+ return false;
+ if (($parts[0] == 192 && $parts[1] == 168) || ($parts[0] == 169 && $parts[1] == 254))
+ return false;
+ if ($parts[0] == 172 && $parts[1] > 15 && $parts[1] < 32)
+ return false;
+
+ return true;
+ }
+
+ /**
+ * Return contents of given file as string, but only read up to maxBytes bytes.
+ *
+ * @param string $file file to read
+ * @param int $maxBytes maximum length to read
+ * @return boolean success or failure
+ */
+ public static function readFile($file, $maxBytes = 1000)
+ {
+ $fh = @fopen($file, 'rb');
+ if ($fh === false)
+ return false;
+ $data = fread($fh, $maxBytes);
+ fclose($fh);
+ return $data;
+ }
+
+}
diff --git a/index.php b/index.php
index 6a58e1ad..cd635b25 100644
--- a/index.php
+++ b/index.php
@@ -130,7 +130,9 @@ Message::renderList();
Page::render();
if (defined('CONFIG_DEBUG') && CONFIG_DEBUG) {
+ Render::openTag('div', array('class' => 'container'));
Message::addWarning('debug-mode');
+ Render::closeTag('div');
}
// Send page to client.
diff --git a/modules/main.inc.php b/modules/main.inc.php
index 232d6a0a..42a980e3 100644
--- a/modules/main.inc.php
+++ b/modules/main.inc.php
@@ -17,13 +17,11 @@ class Page_Main extends Page
return;
}
// Logged in here
- $ipxe = (Property::getServerIp() !== Property::getIPxeIp());
$sysconfig = !file_exists(CONFIG_HTTP_DIR . '/default/config.tgz');
$minilinux = !file_exists(CONFIG_HTTP_DIR . '/default/kernel') || !file_exists(CONFIG_HTTP_DIR . '/default/initramfs-stage31') || !file_exists(CONFIG_HTTP_DIR . '/default/stage32.sqfs');
$vmstore = !is_array(Property::getVmStoreConfig());
Render::addTemplate('page-main', array(
'user' => User::getName(),
- 'ipxe' => $ipxe,
'sysconfig' => $sysconfig,
'minilinux' => $minilinux,
'vmstore' => $vmstore
diff --git a/modules/news.inc.php b/modules/news.inc.php
index 0c3f1f13..fc5c1892 100644
--- a/modules/news.inc.php
+++ b/modules/news.inc.php
@@ -2,11 +2,26 @@
class Page_News extends Page
{
+ /**
+ * Member variables needed to represent a news entry.
+ *
+ * $newsId int ID of the news entry attributed by the database.
+ * $newsTitle string Title of the entry.
+ * $newsContent string Content as text. (TODO: html-Support?)
+ * $newsDate string Unix epoch date of the news' creation.
+ */
private $newsId = false;
private $newsTitle = false;
private $newsContent = false;
private $newsDate = false;
+ /**
+ * Implementation of the abstract doPreprocess function
+ *
+ * Checks if the user is logged in and processes any
+ * action if one was specified in the request.
+ *
+ */
protected function doPreprocess()
{
// load user, we will need it later
@@ -20,26 +35,47 @@ class Page_News extends Page
// check which action we need to do
$action = Request::any('action', 'show');
- if ($action === 'show') {
+ if ($action === 'clear') {
+ // clear news input fields
+ // TODO: is this the right way?
+ $this->newsId = false;
+ $this->newsTitle = false;
+ $this->newsContent = false;
+ $this->newsDate = false;
+ } elseif ($action === 'show') {
// show news
if (!$this->loadNews(Request::any('newsid'))) {
Message::addError('news-empty');
}
} elseif ($action === 'save') {
// save to DB
- $this->saveNews();
+ if (!$this->saveNews()) {
+ // re-set the fields we got
+ Request::post('news-title') ? $this->newsTitle = Request::post('news-title') : $this->newsTitle = false;
+ Request::post('news-content') ? $this->newsContent = Request::post('news-content') : $this->newsContent = false;
+ } else {
+ Message::addSuccess('news-save-success');
+ Util::redirect('?do=News');
+ }
} elseif ($action === 'delete') {
// delete it
$this->delNews(Request::post('newsid'));
} else {
+ // unknown action, redirect user
Message::addError('invalid-action', $action);
Util::redirect('?do=News');
}
}
+ /**
+ * Implementation of the abstract doRender function
+ *
+ * Fetch the list of news from the database and paginate it.
+ *
+ */
protected function doRender()
{
- // prepare the list of the older news
+ // fetch the list of the older news
$lines = array();
$paginate = new Paginate("SELECT newsid, dateline, title, content FROM news ORDER BY dateline DESC", 10);
$res = $paginate->exec();
@@ -57,7 +93,13 @@ class Page_News extends Page
'list' => $lines ));
}
-
+ /**
+ * Loads the news with the given ID into the form.
+ *
+ * @param int $newsId ID of the news to be shown.
+ * @return boolean true if loading that news worked
+ *
+ */
private function loadNews($newsId)
{
// check to see if we need to request a specific newsid
@@ -79,29 +121,41 @@ class Page_News extends Page
return $row !== false;
}
+ /**
+ * Save the given $newsTitle and $newsContent as POST'ed into the database.
+ *
+ */
private function saveNews()
{
// check if news content were set by the user
$newsTitle = Request::post('news-title');
$newsContent = Request::post('news-content');
- if ($newsContent !== false && $newsTitle !== false) {
+ if ($newsContent !== '' && $newsTitle !== '') {
// we got title and content, save it to DB
Database::exec("INSERT INTO news (dateline, title, content) VALUES (:dateline, :title, :content)", array(
'dateline' => time(),
'title' => $newsTitle,
'content' => $newsContent
));
- // all done, redirect to main news page
- Message::addSuccess('news-set-success');
- Util::redirect('?do=News');
+ return true;
+ } else {
+ Message::addError('empty-field');
+ return false;
}
}
+ /**
+ * Delete the news entry with ID $newsId
+ *
+ * @param int $newsId ID of the entry to be deleted.
+ */
private function delNews($newsId)
{
+ // sanity check: is newsId even numeric?
if (!is_numeric($newsId)) {
Message::addError('value-invalid', 'newsid', $newsId);
} else {
+ // check passed - do delete
Database::exec("DELETE FROM news WHERE newsid = :newsid LIMIT 1", array(
'newsid' => $newsId
));
diff --git a/modules/serversetup.inc.php b/modules/serversetup.inc.php
index c03fe9e3..527c0940 100644
--- a/modules/serversetup.inc.php
+++ b/modules/serversetup.inc.php
@@ -45,7 +45,6 @@ class Page_ServerSetup extends Page
'ips' => $this->taskStatus['data']['addresses']
));
$data = $this->currentMenu;
- $data['taskid'] = Property::getIPxeTaskId();
if (!isset($data['defaultentry']))
$data['defaultentry'] = 'net';
if ($data['defaultentry'] === 'net')
@@ -101,7 +100,6 @@ class Page_ServerSetup extends Page
}
if ($valid) {
Property::setServerIp($newAddress);
- Trigger::ipxe();
} else {
Message::addError('invalid-ip', $newAddress);
}
@@ -120,7 +118,7 @@ class Page_ServerSetup extends Page
$this->currentMenu['timeout'] = $timeout;
$this->currentMenu['custom'] = Request::post('custom', '');
Property::setBootMenu($this->currentMenu);
- Trigger::ipxe(true);
+ Trigger::ipxe();
Util::redirect('?do=ServerSetup');
}
diff --git a/modules/sysconfig.inc.php b/modules/sysconfig.inc.php
index 724e288c..8be001e6 100644
--- a/modules/sysconfig.inc.php
+++ b/modules/sysconfig.inc.php
@@ -130,7 +130,7 @@ class Page_SysConfig extends Page
$configs[] = array(
'configid' => $row['configid'],
'config' => $row['title'],
- 'current' => readlink('/srv/openslx/www/boot/default/config.tgz') === $row['filepath']
+ 'current' => readlink(CONFIG_HTTP_DIR . '/default/config.tgz') === $row['filepath']
);
}
// Config modules
@@ -142,7 +142,7 @@ class Page_SysConfig extends Page
'module' => $row['title']
);
}
- Render::addTemplate('page-sysconfig-main', array(
+ Render::addTemplate('sysconfig/_page', array(
'configs' => $configs,
'modules' => $modules
));
diff --git a/modules/sysconfig/addconfig.inc.php b/modules/sysconfig/addconfig.inc.php
index 21ac93b4..1e2e870a 100644
--- a/modules/sysconfig/addconfig.inc.php
+++ b/modules/sysconfig/addconfig.inc.php
@@ -134,8 +134,7 @@ class AddConfig_Start extends AddConfig_Base
}
/**
- * Start dialog for adding config. Ask for title,
- * show selection of modules.
+ * Success dialog if adding config worked.
*/
class AddConfig_Finish extends AddConfig_Base
{
diff --git a/modules/sysconfig/addmodule_branding.inc.php b/modules/sysconfig/addmodule_branding.inc.php
new file mode 100644
index 00000000..6515a850
--- /dev/null
+++ b/modules/sysconfig/addmodule_branding.inc.php
@@ -0,0 +1,213 @@
+<?php
+
+/*
+ * Wizard for including a branding logo.
+ */
+
+Page_SysConfig::addModule('BRANDING', 'Branding_Start', 'Logo der Einrichtung', 'Dieses Modul dient dem Hinzufügen eines Logos der Hochschule/Universität, welches'
+ . ' dann z.B. auf dem Anmeldebildschirm angezeigt wird.', 'Branding', false
+);
+
+class Branding_Start extends AddModule_Base
+{
+
+ protected function renderInternal()
+ {
+ Render::addDialog('Einrichtungsspezifisches Logo', false, 'sysconfig/branding-start', array(
+ 'step' => 'Branding_ProcessFile',
+ ));
+ }
+
+}
+
+class Branding_ProcessFile extends AddModule_Base
+{
+
+ private $task;
+ private $svgFile;
+ private $tarFile;
+
+ protected function preprocessInternal()
+ {
+ $url = Request::post('url');
+ if ((!isset($_FILES['file']['error']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) && empty($url)) {
+ Message::addError('empty-field');
+ Util::redirect('?do=SysConfig&action=addmodule&step=Branding_Start');
+ }
+
+ $this->svgFile = tempnam(sys_get_temp_dir(), 'bwlp-');
+ if (isset($_FILES['file']['error']) && $_FILES['file']['error'] !== UPLOAD_ERR_NO_FILE) {
+ // Prefer uploaded image over URL (in case both are given)
+ if ($_FILES['file']['error'] !== UPLOAD_ERR_OK) {
+ Message::addError('upload-failed', Util::uploadErrorString($_FILES['file']['error']));
+ Util::redirect('?do=SysConfig&action=addmodule&step=Branding_Start');
+ }
+ if (!move_uploaded_file($_FILES["file"]["tmp_name"], $this->svgFile)) {
+ Message::addError('upload-failed', 'Moving temp file failed');
+ Util::redirect('?do=SysConfig&action=addmodule&step=Branding_Start');
+ }
+ } else {
+ // URL - launch task that fetches the SVG file from it
+ if (strpos($url, '://') === false)
+ $url = "http://$url";
+ if (!$this->downloadSvg($this->svgFile, $url))
+ Util::redirect('?do=SysConfig&action=addmodule&step=Branding_Start');
+ }
+ chmod($this->svgFile, 0644);
+ $this->tarFile = '/tmp/bwlp-' . time() . '-' . mt_rand() . '.tgz';
+ $this->task = Taskmanager::submit('BrandingGenerator', array(
+ 'tarFile' => $this->tarFile,
+ 'svgFile' => $this->svgFile
+ ));
+ $this->task = Taskmanager::waitComplete($this->task);
+ if (Taskmanager::isFailed($this->task)) {
+ @unlink($this->svgFile);
+ Taskmanager::addErrorMessage($this->task);
+ Util::redirect('?do=SysConfig&action=addmodule&step=Branding_Start');
+ }
+ Session::set('logo_tgz', $this->tarFile);
+ Session::save();
+ }
+
+ protected function renderInternal()
+ {
+ $svg = $png = false;
+ if (isset($this->task['data']['pngFile']))
+ $png = base64_encode(file_get_contents($this->task['data']['pngFile']));
+ if (filesize($this->svgFile) < 1000000)
+ $svg = base64_encode(file_get_contents($this->svgFile));
+ Render::addDialog('Einrichtungsspezifisches Logo', false, 'sysconfig/branding-check', array(
+ 'png' => $png,
+ 'svg' => $svg,
+ 'error' => $this->task['data']['error'],
+ 'step' => 'Branding_Finish',
+ )
+ );
+ @unlink($this->svgFile);
+ }
+
+ /**
+ * Downlaod an svg file from the given url. This function has "wikipedia support", it tries to detect
+ * URLs in wikipedia articles or thumbnails and then find the actual svg file.
+ *
+ * @param string $svgName file to download to
+ * @param string $url url to download from
+ * @return boolean true of download succeded, false on download error (also returns true if downloaded file doesn't
+ * seem to be svg!)
+ */
+ private function downloadSvg($svgName, $url)
+ {
+ // [wikipedia] Did someone paste a link to a thumbnail of the svg? Let's fix that...
+ if (preg_match('#^(.*)/thumb/(.*\.svg)/.*\.svg#', $url, $out)) {
+ $url = $out[1] . '/' . $out[2];
+ }
+ for ($i = 0; $i < 5; ++$i) {
+ $code = 400;
+ if (!Download::toFile($svgName, $url, 3, $code) || $code < 200 || $code > 299) {
+ Message::addError('remote-timeout', $url, $code);
+ return false;
+ }
+ $content = Util::readFile($svgName, 25000);
+ // Is svg file?
+ if (strpos($content, '<svg') !== false)
+ return true; // Found an svg tag - don't try to find links to the actual image
+
+ // [wikipedia] Try to be nice and detect links that might give a hint where the svg can be found
+ if (preg_match_all('#href="([^"]*upload.wikimedia.org/[^"]*/[^"]*/[^"]*\.svg|[^"]+/[^"]+:[^"]+\.svg[^"]*)"#', $content, $out, PREG_PATTERN_ORDER)) {
+ foreach ($out[1] as $res) {
+ if (!strpos($res, 'action=edit')) {
+ $new = $this->internetCombineUrl($url, html_entity_decode($res, ENT_COMPAT, 'UTF-8'));
+ if ($new !== $url)
+ break;
+ }
+ }
+ if ($new === $url)
+ break;
+ $url = $new;
+ continue;
+ }
+ break;
+ }
+ return true;
+ }
+
+ /**
+ * Make relative url absolute.
+ *
+ * @param string $absolute absolute url to use as base
+ * @param string $relative relative url that will be converted to an absolute url
+ * @return string combined absolute url
+ */
+ private function internetCombineUrl($absolute, $relative)
+ {
+ $p = parse_url($relative);
+ if (!empty($p["scheme"]))
+ return $relative;
+
+ extract(parse_url($absolute));
+
+ $path = dirname($path);
+
+ if ($relative{0} === '/') {
+ if ($relative{1} === '/')
+ return "$scheme:$relative";
+ $cparts = array_filter(explode("/", $relative));
+ } else {
+ $aparts = array_filter(explode("/", $path));
+ $rparts = array_filter(explode("/", $relative));
+ $cparts = array_merge($aparts, $rparts);
+ foreach ($cparts as $i => $part) {
+ if ($part == '.') {
+ $cparts[$i] = null;
+ }
+ if ($part == '..') {
+ $cparts[$i - 1] = null;
+ $cparts[$i] = null;
+ }
+ }
+ $cparts = array_filter($cparts);
+ }
+ $path = implode("/", $cparts);
+ $url = "";
+ if ($scheme)
+ $url = "$scheme://";
+ if (!empty($user)) {
+ $url .= "$user";
+ if (!empty($pass)) {
+ $url .= ":$pass";
+ }
+ $url .= "@";
+ }
+ if ($host)
+ $url .= "$host/";
+ $url .= $path;
+ return $url;
+ }
+
+}
+
+class Branding_Finish extends AddModule_Base
+{
+
+ protected function preprocessInternal()
+ {
+ $title = Request::post('title');
+ if ($title === false || empty($title)) {
+ Message::addError('missing-file'); // TODO: Ask for title again instead of starting over
+ Util::redirect('?do=SysConfig&action=addmodule&step=Branding_Start');
+ }
+ $tgz = Session::get('logo_tgz');
+ if ($tgz === false || !file_exists($tgz)) {
+ Message::addError('missing-file');
+ Util::redirect('?do=SysConfig&action=addmodule&step=Branding_Start');
+ }
+ if (!ConfigModule::insertBrandingModule($title, $tgz))
+ Util::redirect('?do=SysConfig&action=addmodule&step=Branding_Start');
+ Session::set('logo_tgz', false);
+ Session::save();
+ // Yay
+ Message::addSuccess('module-added');
+ Util::redirect('?do=SysConfig');
+ }
+
+}
diff --git a/templates/serversetup/ipaddress.html b/templates/serversetup/ipaddress.html
index 0352ab90..acfbb408 100644
--- a/templates/serversetup/ipaddress.html
+++ b/templates/serversetup/ipaddress.html
@@ -10,7 +10,7 @@
<form method="post" action="?do=ServerSetup">
<input type="hidden" name="action" value="ip">
<input type="hidden" name="token" value="{{token}}">
- <table>
+ <table class="slx-table">
{{#ips}}
<tr>
<td>{{ip}}</td>
diff --git a/templates/serversetup/ipxe.html b/templates/serversetup/ipxe.html
index dddc0ecc..772777d1 100644
--- a/templates/serversetup/ipxe.html
+++ b/templates/serversetup/ipxe.html
@@ -31,11 +31,8 @@
<strong>{{lang_menuCustom}}</strong> <a class="btn btn-default btn-xs" data-toggle="modal" data-target="#help-custom"><span class="glyphicon glyphicon-question-sign"></span></a>
<textarea class="form-control" name="custom">{{custom}}</textarea>
</div>
-
-
- <br>
- <div data-tm-id="{{taskid}}" data-tm-log="error"> Status</div>
</div>
+
<div class="panel-footer">
<button class="btn btn-primary" name="action" value="ipxe">{{lang_bootMenuCreate}}</button>
</div>
diff --git a/templates/page-sysconfig-main.html b/templates/sysconfig/_page.html
index 086ea9af..086ea9af 100644
--- a/templates/page-sysconfig-main.html
+++ b/templates/sysconfig/_page.html
diff --git a/templates/sysconfig/branding-check.html b/templates/sysconfig/branding-check.html
new file mode 100644
index 00000000..93be4f2d
--- /dev/null
+++ b/templates/sysconfig/branding-check.html
@@ -0,0 +1,29 @@
+<p>
+ Unten sehen Sie zur Kontrolle noch einmal das ausgewählte Logo. Sollten Sie das Logo
+ nicht sehen können, prüfen Sie bitte, ob Sie ein valides SVG-Bild verwendet haben.
+ Alternativ ist es möglich, dass beim Verarbeiten des Bildes ein Fehler auftrat. Sie
+ können daher das Modul trotzdem speichern und testen, ob das Logo im bwLehrpool-System
+ angezeigt wird.
+</p>
+<div class="pull-left">
+ {{#svg}}
+ <img src="data:image/svg+xml;base64,{{svg}}" width="192" height="192">
+ {{/svg}}
+</div>
+<div class="pull-right">
+ {{#png}}
+ <img src="data:image/png;base64,{{png}}">
+ {{/png}}
+</div>
+<div class="clearfix"></div>
+<div>{{error}}</div>
+<div>
+ <form role="form" enctype="multipart/form-data" method="post" action="?do=SysConfig&amp;action=addmodule&amp;step={{step}}">
+ <input type="hidden" name="token" value="{{token}}">
+ <div class="form-group">
+ <label for="title-id">Titel</label>
+ <input type="text" name="title" id ="title-id" class="form-control" placeholder="Name des Moduls">
+ </div>
+ <button type="submit" class="btn btn-primary">Speichern</button>
+ </form>
+</div>
diff --git a/templates/sysconfig/branding-start.html b/templates/sysconfig/branding-start.html
new file mode 100644
index 00000000..d26a5dee
--- /dev/null
+++ b/templates/sysconfig/branding-start.html
@@ -0,0 +1,19 @@
+<p>
+ Für beste Ergebnisse sollten Sie ihr Einrichtungslogo im SVG Format hochladen.
+ das SVG-Format ist ein Vektorgrafikformat, was zum Skalieren vorteilhaft ist.
+ Eine Gute Quelle für SVG-Logos von Unis und Hochschulen ist ihr jeweiliger Wikipedia-Artikel.
+</p>
+<form role="form" enctype="multipart/form-data" method="post" action="?do=SysConfig&amp;action=addmodule&amp;step={{step}}">
+ <input type="hidden" name="token" value="{{token}}">
+ <div class="form-group">
+ <label for="input-url">Bild von URL laden</label>
+ <input class="form-control" type="text" name="url" id="input-url">
+ </div>
+ oder
+ <div class="form-group">
+ <label for="input-file">Bild von lokalem Rechner hochladen</label>
+ <input class="form-control" type="file" name="file" id="input-file">
+ </div>
+ <button type="submit" class="btn btn-primary">Hochladen</button>
+</form>
+