Browse Source

FileApi start

Roman Hergenreder 3 years ago
parent
commit
8747812a56

+ 2 - 2
core/Api/ApiKeyAPI.class.php

@@ -162,8 +162,8 @@ namespace Api\ApiKey {
       $this->loginRequired = true;
     }
 
-    public function execute($aValues = array()) {
-      if(!parent::execute($aValues)) {
+    public function execute($values = array()) {
+      if(!parent::execute($values)) {
         return false;
       }
 

+ 12 - 0
core/Api/ContactAPI.class.php

@@ -53,6 +53,7 @@ namespace Api\Contact {
       }
 
       $this->createNotification();
+      $this->sendMail();
 
       if (!$this->success) {
         return $this->createError("The contact request was saved, but the server was unable to create a notification.");
@@ -110,6 +111,17 @@ namespace Api\Contact {
 
       return $this->success;
     }
+
+    private function sendMail() {
+      /*$email = $this->getParam("fromEmail");
+      $settings = $this->user->getConfiguration()->getSettings();
+      $request = new \Api\Mail\Send($this->user);
+      $this->success = $request->execute(array(
+        "to" => $settings->get,
+        "subject" => "[$siteName] Account Invitation",
+        "body" => $messageBody
+      ));*/
+    }
   }
 
 }

+ 737 - 0
core/Api/FileAPI.class.php

@@ -0,0 +1,737 @@
+<?php
+
+namespace Api {
+
+  use Driver\SQL\Condition\Compare;
+
+  abstract class FileAPI extends Request {
+
+    protected function checkDirectory($parentId) {
+      if ($parentId === null) {
+        return true;
+      }
+
+      $sql = $this->user->getSQL();
+      $res = $sql->select("directory")
+        ->from("UserFile")
+        ->where(new Compare("uid", $parentId))
+        ->limit(1)
+        ->execute();
+
+      $this->success = ($res !== false);
+      $this->lastError = $sql->getLastError();
+
+      if ($this->success) {
+        if (empty($res)) {
+          return $this->createError("Parent directory not found");
+        } else if(!$res[0]["directory"]) {
+          return $this->createError("Parent file is not a directory");
+        }
+      }
+
+      return $this->success;
+    }
+
+    protected function downloadFile($name, $path) {
+      if (!file_exists($path)) {
+        http_response_code(404);
+        die("404 - File does not exist anymore");
+      } else if(!is_file($path) || !is_readable($path)) {
+        die("403 - Unable to download file.");
+      } else {
+        $mimeType = @mime_content_type($path);
+        if ($mimeType) {
+          header("Content-Type: $mimeType");
+        }
+
+        $name = trim(preg_replace('/\s\s+/', ' ', $name));
+        header("Content-Disposition: attachment; filename=$name");
+
+        //No cache
+        header('Expires: 0');
+        header('Cache-Control: must-revalidate');
+        header('Pragma: public');
+
+        //Define file size
+        header('Content-Length: ' . filesize($path));
+
+        ob_clean();
+        flush();
+        readfile($path);
+        exit;
+      }
+    }
+
+    protected function isInDirectory($fileId, $directoryId) {
+
+      $sql = $this->user->getSQL();
+
+
+
+    }
+  }
+}
+
+namespace Api\File {
+
+  use Api\FileAPI;
+  use Api\Parameter\Parameter;
+  use Api\Parameter\StringType;
+  use Driver\SQL\Condition\Compare;
+  use Driver\SQL\Condition\CondIn;
+  use Objects\User;
+
+  class ValidateToken extends FileAPI {
+
+    public function __construct(User $user, bool $externalCall = false) {
+      parent::__construct($user, $externalCall, array(
+        'token' => new StringType('token', 36, false)
+      ));
+      $this->csrfTokenRequired = false;
+    }
+
+    public function execute($values = array()) {
+      if (!parent::execute($values)) {
+        return false;
+      }
+
+      $sql = $this->user->getSQL();
+      $token = $this->getParam("token");
+      $res = $sql->select("UserFile.uid", "valid_until", "token_type", "maxFiles", "maxSize", "extensions", "name", "path", "directory")
+        ->from("UserFileToken")
+        ->leftJoin("UserFileTokenFile", "UserFileToken.uid", "token_id")
+        ->innerJoin("UserFile", "UserFile.uid", "file_id")
+        ->where(new Compare("token", $token))
+        ->where(new Compare("valid_until", $sql->now(), ">"))
+        ->execute();
+
+      $this->success = ($res !== false);
+      $this->lastError = $sql->getLastError();
+
+      if ($this->success) {
+        if (empty($res)) {
+          return $this->createError("Invalid Token");
+        } else {
+          $row = $res[0];
+          $this->result["token"] = array(
+            "type" => $row["token_type"],
+            "valid_until" => $row["valid_until"]
+          );
+
+          $this->result["files"] = array();
+          foreach ($res as $row) {
+            $file = array(
+              "isDirectory" => $row["directory"],
+              "name" => $row["name"],
+              "uid" => $row["uid"]
+            );
+
+            if ($file["isDirectory"]) {
+              $file["items"] = array();
+            } else {
+              $file["size"] = @filesize($row["path"]);
+              $file["mimeType"] = @mime_content_type($row["path"]);
+            }
+
+            $this->result["files"][] = $file;
+          }
+
+          if ($row["token_type"] === "upload") {
+            $this->result["restrictions"] = array(
+              "maxFiles" => $row["maxFiles"] ?? 0,
+              "maxSize"  => $row["maxSize"] ?? 0,
+              "extensions" => $row["extensions"] ?? ""
+            );
+          }
+        }
+      }
+
+      return $this->success;
+    }
+  }
+
+  class RevokeToken extends FileAPI {
+    public function __construct(User $user, bool $externalCall = false) {
+      parent::__construct($user, $externalCall, array(
+        "token" => new StringType("token", 36)
+      ));
+      $this->loginRequired = true;
+      $this->csrfTokenRequired = true;
+    }
+
+    public function execute($values = array()) {
+      if (!parent::execute($values)) {
+        return false;
+      }
+
+      $sql = $this->user->getSQL();
+      $token = $this->getParam("token");
+      $res = $sql->select($sql->count())
+        ->from("UserToken")
+        ->where(new Compare("user_id", $this->user->getId()))
+        ->where(new Compare("token", $token))
+        ->execute();
+
+      $this->success = ($res !== false);
+      $this->lastError = $sql->getLastError();
+
+      if ($this->success) {
+        if(empty($res)) {
+          return $this->getParam("Invalid token");
+        } else {
+          $res = $sql->update("UserToken")
+            ->set("valid_until", new \DateTime())
+            ->where(new Compare("user_id", $this->user->getId()))
+            ->where(new Compare("token", $token))
+            ->execute();
+          $this->success = ($res !== false);
+          $this->lastError = $sql->getLastError();
+        }
+      }
+
+      return $this->success;
+    }
+  }
+
+  class ListFiles extends FileAPI {
+
+    public function __construct(User $user, bool $externalCall = false) {
+      parent::__construct($user, $externalCall, array(
+        "directory" => new Parameter("directory", Parameter::TYPE_INT, true, null)
+      ));
+      $this->loginRequired = true;
+      $this->csrfTokenRequired = false;
+    }
+
+    private function &findDirectory(&$files, $id) {
+
+      if ($id !== null) {
+        $id = (string)$id;
+        if (isset($files[$id])) {
+          return $files[$id]["items"];
+        } else {
+          foreach ($files as &$dir) {
+            if ($dir["isDirectory"]) {
+              $target =& $this->findDirectory($dir["items"], $id);
+              if ($target !== $dir) {
+                return $target;
+              }
+            }
+          }
+          return $files;
+        }
+      } else {
+        return $files;
+      }
+    }
+
+    public function execute($values = array()) {
+      if (!parent::execute($values)) {
+        return false;
+      }
+
+      $sql = $this->user->getSQL();
+      $res = $sql->select(
+          "UserFile.uid", "UserFile.directory", "UserFile.path", "UserFile.name",
+          "UserFile.user_id", "parentTable.uid as parentId", "parentTable.name as parentName")
+        ->from("UserFile")
+        ->leftJoin("UserFile", "UserFile.parent_id", "parentTable.uid", "parentTable")
+        ->where(new Compare("UserFile.user_id", $this->user->getId()))
+        ->orderBy("UserFile.parent_id")
+        ->ascending()
+        ->execute();
+
+      $this->success = ($res !== false);
+      $this->lastError = $sql->getLastError();
+
+      if ($this->success) {
+        $files = array();
+
+        foreach ($res as $row) {
+          $fileId = (string)$row["uid"];
+          $parentId = $row["parentId"];
+          $fileName = $row["name"];
+          $isDirectory = $row["directory"];
+          $fileElement = array("uid" => $fileId, "name" => $fileName, "isDirectory" => $isDirectory);
+          if ($isDirectory) {
+            $fileElement["items"] = array();
+          } else {
+            $fileElement["size"] = @filesize($row["path"]);
+            $fileElement["mimeType"] = @mime_content_type($row["path"]);
+          }
+
+          $dir =& $this->findDirectory($files, $parentId);
+          $dir[$fileId] = $fileElement;
+          unset($dir);
+        }
+
+        $directoryId = $this->getParam("directory");
+        if (!is_null($directoryId)) {
+          $wantedDir =& $this->findDirectory($files, $directoryId);
+          if ($files === $wantedDir) {
+            $files = array();
+          } else {
+            $files = $wantedDir;
+          }
+        }
+
+        $this->result["files"] = $files;
+      }
+
+      return $this->success;
+    }
+  }
+
+  class ListTokens extends FileAPI {
+
+    public function __construct(User $user, bool $externalCall = false) {
+      parent::__construct($user, $externalCall, array());
+      $this->loginRequired = true;
+      $this->csrfTokenRequired = false;
+    }
+
+    public function execute($values = array()) {
+      if (!parent::execute($values)) {
+        return false;
+      }
+
+      $sql = $this->user->getSQL();
+      $res = $sql->select("uid","token","valid_until","token_type", "maxFiles", "maxSize", "extensions")
+        ->from("UserFileToken")
+        ->orderBy("valid_until")
+        ->descending()
+        ->execute();
+
+      $this->success = ($res !== false);
+      $this->lastError = $sql->getLastError();
+
+      if ($this->success) {
+        $this->result["tokens"] = array();
+        foreach ($res as $row) {
+          $tokenType = $row["token_type"];
+          if ($tokenType !== "upload") {
+            unset($row["maxFiles"]);
+            unset($row["maxSize"]);
+            unset($row["extensions"]);
+          }
+          $this->result["tokens"][] = $row;
+        }
+      }
+
+      return $this->success;
+    }
+  }
+
+  class CreateDirectory extends FileAPI {
+    public function __construct(User $user, bool $externalCall = false) {
+      parent::__construct($user, $externalCall, array(
+        'name' => new StringType('name', 32),
+        'parentId' => new Parameter('parentId', Parameter::TYPE_INT, true, null)
+      ));
+      $this->loginRequired = true;
+      $this->csrfTokenRequired = false;
+    }
+
+    public function execute($values = array()) {
+      if (!parent::execute($values)) {
+        return false;
+      }
+
+      $sql = $this->user->getSQL();
+      $name = $this->getParam('name');
+      $parentId = $this->getParam("parentId");
+      if (!$this->checkDirectory($parentId)) {
+        return $this->success;
+      }
+
+      $res = $sql->insert("UserFile", array("directory", "name", "user_id", "parent_id"))
+        ->addRow(true, $name, $this->user->getId(), $parentId)
+        ->execute();
+
+      $this->success = ($res !== false);
+      $this->lastError = $sql->getLastError();
+
+      return $this->success;
+    }
+  }
+
+  class Rename extends FileAPI { }
+
+  class Move extends FileAPI { }
+
+  class Upload extends FileAPI {
+
+    public function __construct(User $user, bool $externalCall = false) {
+      parent::__construct($user, $externalCall, array(
+        'parentId' => new Parameter('parentId', Parameter::TYPE_INT, true, null),
+        'token' => new StringType('token', 36, true, null)
+      ));
+      $this->csrfTokenRequired = false;
+    }
+
+    public function execute($values = array()) {
+      if (!parent::execute($values)) {
+        return false;
+      }
+
+      $token = $this->getParam("token");
+      if (!$this->user->isLoggedIn() && is_null($token)) {
+        return $this->createError("Permission denied (expected token)");
+      }
+
+      $sql = $this->user->getSQL();
+      $parentId = $this->getParam("parentId");
+      if (!is_null($token) && !is_null($parentId)) {
+        return $this->createError("Cannot upload to parent directory using token");
+      }
+
+      if (!is_null($parentId) && !$this->checkDirectory($parentId)) {
+        return $this->success;
+      }
+
+      $fileKeys = array_keys($_FILES);
+      $numFilesUploaded = count($fileKeys);
+
+      if (!is_null($token)) {
+
+        $res = $sql->select("uid",  "token_type", "maxFiles", "maxSize", "extensions", "user_id")
+          ->from("UserFileToken")
+          ->where(new Compare("token", $token))
+          ->where(new Compare("valid_until", $sql->now(), ">="))
+          ->limit(1)
+          ->execute();
+
+        $this->success = ($res !== false);
+        $this->lastError = $sql->getLastError();
+        if (!$this->success) {
+          return false;
+        }
+
+        if (empty($res) || $res[0]["token_type"] !== "upload") {
+          return $this->createError("Permission denied (token)");
+        }
+
+        $tokenId = $res[0]["uid"];
+        $maxFiles = $res[0]["maxFiles"] ?? 0;
+        $maxSize = $res[0]["maxSize"] ?? 0;
+        $userId = $res[0]["user_id"];
+        $extensions = explode(",", strtolower($res[0]["extensions"] ?? ""));
+
+        $res = $sql->select($sql->count())
+          ->from("UserFileTokenFile")
+          ->innerJoin("UserFileToken", "token_id", "UserFileToken.uid")
+          ->execute();
+
+        $this->success = ($res !== false);
+        $this->lastError = $sql->getLastError();
+        if (!$this->success) {
+          return false;
+        }
+
+        $count = $res[0]["count"];
+        if ($maxFiles > 0 && $numFilesUploaded > 0 && $numFilesUploaded + $count > $maxFiles) {
+          return $this->createError("File limit exceeded. Currently uploaded $count / $maxFiles count");
+        }
+
+        if ($maxSize > 0 || !empty($extensions)) {
+          foreach ($_FILES as $file) {
+            $name = $file["name"];
+            if ($maxSize > 0 && $file["size"] > $maxSize) {
+              return $this->createError("File Size limit of $maxSize bytes exceeded for file $name");
+            }
+
+            $dotPos = strrpos($name, ".");
+            $ext = ($dotPos !== false ? strtolower(substr($name, $dotPos + 1)) : false);
+            if (!empty($extensions) && $ext !== false && !in_array($ext, $extensions)) {
+              return $this->createError("File '$name' has prohibited extension. Allowed extensions: " . implode(",", $extensions));
+            }
+          }
+        }
+      } else {
+        $userId = $this->user->getId();
+      }
+
+      if ($numFilesUploaded === 0) {
+        return $this->createError("No file uploaded");
+      }
+
+      $uploadDir = realpath($_SERVER["DOCUMENT_ROOT"] . "/files/uploaded/");
+      if (!is_writable($uploadDir)) {
+        return $this->createError("Upload directory is not writable");
+      }
+
+      $fileIds = array();
+      foreach ($_FILES as $key => $file) {
+        $fileName = $file["name"];
+        $tmpPath = $file["tmp_name"];
+        $md5Hash = hash_file('md5', $tmpPath);
+        $sha1Hash = hash_file('sha1', $tmpPath);
+        $filePath =  $uploadDir . "/" . $md5Hash . $sha1Hash;
+        if (move_uploaded_file($tmpPath, $filePath)) {
+          $res = $sql->insert("UserFile", array("name", "directory", "path", "user_id", "parent_id"))
+            ->addRow($fileName, false, $filePath, $userId, $parentId)
+            ->returning("uid")
+            ->execute();
+
+          if ($res === false) {
+            $this->lastError = $sql->getLastError();
+            $this->success = false;
+            return false;
+          } else {
+            $fileIds[] = $sql->getLastInsertId();
+          }
+        } else {
+          return $this->createError("Could not create file: " . $fileName);
+        }
+      }
+
+      if (!is_null($token)) {
+        $query = $sql->insert("UserFileTokenFile", array("file_id", "token_id"));
+        foreach ($fileIds as $fileId) {
+          $query->addRow($fileId, $tokenId);
+        }
+
+        $res = $query->execute();
+        $this->success = ($res !== false);
+        $this->lastError = $sql->getLastError();
+      }
+
+      return $this->success;
+    }
+  }
+
+  class Download extends FileAPI {
+
+    public function __construct(User $user, bool $externalCall = false) {
+      parent::__construct($user, $externalCall, array(
+        "id" => new Parameter("id", Parameter::TYPE_INT),
+        "token" => new StringType("token", 36, true, null)
+      ));
+      $this->csrfTokenRequired = false;
+    }
+
+    public function execute($values = array()) {
+      if (!parent::execute($values)) {
+        return false;
+      }
+
+      $token = $this->getParam("token");
+      if (!$this->user->isLoggedIn() && is_null($token)) {
+        return $this->createError("Permission denied (expected token)");
+      }
+
+      $sql = $this->user->getSQL();
+      $fileId = $this->getParam("id");
+      $query =  $sql->select("path", "name")
+        ->from("UserFile")
+        ->where(new Compare("UserFile.uid", $fileId));
+
+      if (is_null($token)) {
+        $query->where(new Compare("user_id", $this->user->getId()));
+      } else {
+        $query->innerJoin("UserFileTokenFile", "UserFile.uid", "file_id")
+          ->innerJoin("UserFileToken", "UserFileToken.uid", "token_id")
+          ->where(new Compare("token", $token));
+      }
+
+      $res = $query->execute();
+      $this->success = ($res !== false);
+      $this->lastError = $sql->getLastError();
+
+      if ($this->success) {
+        if (empty($res)) {
+          if (is_null($token)) {
+            return $this->createError("File not found");
+          } else {
+            return $this->createError("Permission denied (token)");
+          }
+        } else {
+          if ($res[0]["directory"]) {
+            return $this->createError("Cannot download directory (yet)");
+          } else {
+            $path = $res[0]["path"];
+            $name = $res[0]["name"];
+            $this->downloadFile($name, $path);
+          }
+        }
+      }
+
+      return $this->success;
+    }
+  }
+
+  class Delete extends FileAPI {
+
+    public function __construct(User $user, bool $externalCall = false) {
+      parent::__construct($user, $externalCall, array(
+        "id" => new Parameter("id", Parameter::TYPE_INT),
+        "token" => new StringType("token", 36, true, null)
+      ));
+    }
+
+    public function execute($values = array()) {
+      if (!parent::execute($values)) {
+        return false;
+      }
+
+
+      return $this->success;
+    }
+  }
+
+  class CreateUploadToken extends FileAPI {
+    public function __construct(User $user, bool $externalCall = false) {
+      parent::__construct($user, $externalCall, array(
+        "maxFiles" => new Parameter("maxFiles", Parameter::TYPE_INT, true, 1),
+        "maxSize"  => new Parameter("maxSize", Parameter::TYPE_INT, true, null),
+        "extensions" => new StringType("extensions", 64, true, null),
+        "durability" => new Parameter("durability", Parameter::TYPE_INT, true, 60*24*2)
+      ));
+      $this->loginRequired = true;
+      $this->csrfTokenRequired = false;
+    }
+
+    public function execute($values = array()) {
+      if (!parent::execute($values)) {
+        return false;
+      }
+
+      $maxFiles = $this->getParam("maxFiles");
+      $maxSize  = $this->getParam("maxSize");
+      $extensions = $this->getParam("extensions");
+      $durability = $this->getParam("durability");
+
+      if (!is_null($maxFiles) && $maxFiles < 0) {
+        return $this->createError("Invalid number of maximum files.");
+      }
+
+      if (!is_null($maxSize) && $maxSize < 0) {
+        return $this->createError("Invalid maximum size for uploaded files.");
+      }
+
+      if (!is_null($durability) && $durability < 0) {
+        return $this->createError("Invalid durability.");
+      }
+
+      if (!is_null($extensions)) {
+        $extensions = explode(",",$extensions);
+        foreach ($extensions as $i => $ext) {
+          if (strlen($ext) === 0 || (strlen($ext) === 1 && $ext[0] === ".")) {
+            unset ($extensions[$i]);
+          } else if ($ext[0] === ".") {
+            $extensions[$i] = substr($ext, 1);
+          }
+        }
+        $extensions = implode(",", $extensions);
+      }
+
+      $sql = $this->user->getSQL();
+      $token = generateRandomString(36);
+      $validUntil = (new \DateTime())->modify("+$durability HOURS");
+
+      $res = $sql->insert("UserFileToken",
+          array("token", "token_type", "maxSize", "maxFiles", "extensions", "valid_until", "user_id"))
+        ->addRow($token, "upload", $maxSize, $maxFiles, $extensions, $validUntil, $this->user->getId())
+        ->returning("uid")
+        ->execute();
+
+      $this->success = ($res !== false);
+      $this->lastError = $sql->getLastError();
+
+      if ($this->success) {
+        $this->result["token"] = $token;
+        $this->result["tokenId"] = $sql->getLastInsertId();
+      }
+
+      return $this->success;
+    }
+  }
+
+  class CreateDownloadToken extends FileAPI {
+
+    public function __construct(User $user, bool $externalCall = false) {
+      parent::__construct($user, $externalCall, array(
+        "durability" => new Parameter("durability", Parameter::TYPE_INT, true, 60*24*2),
+        "files" => new Parameter("files", Parameter::TYPE_ARRAY, false)
+      ));
+      $this->loginRequired = true;
+      $this->csrfTokenRequired = false;
+    }
+
+    public function execute($values = array()) {
+      if (!parent::execute($values)) {
+        return false;
+      }
+
+      $durability = $this->getParam("durability");
+      $fileIds = $this->getParam("files");
+
+      if (!is_null($durability) && $durability < 0) {
+        return $this->createError("Invalid durability.");
+      }
+
+      foreach ($fileIds as $fileId) {
+        if (!is_int($fileId) && is_numeric($fileId)) {
+          $fileId = intval($fileId);
+        }
+        if (!is_int($fileId) || $fileId < 1) {
+          return $this->createError("Invalid file id: $fileId");
+        }
+      }
+
+      $fileIds = array_unique($fileIds);
+
+      // Check for files:
+      $sql = $this->user->getSQL();
+      $res = $sql->select("uid")
+        ->from("UserFile")
+        ->where(new CondIn("uid", $fileIds))
+        ->execute();
+
+      $this->success = ($res !== false);
+      $this->lastError = $sql->getLastError();
+      if (!$this->success) {
+        return false;
+      }
+
+      if (count($res) !== count($fileIds)) {
+        $foundFiles = array_map(function ($row) { return $row["uid"]; }, $res);
+        foreach ($fileIds as $fileId) {
+          if (!in_array($fileId, $foundFiles)) {
+            return $this->createError("File not found: $fileId");
+          }
+        }
+      }
+
+      // Insert
+      $token = generateRandomString(36);
+      $validUntil = (new \DateTime())->modify("+$durability HOURS");
+      $res = $sql->insert("UserFileToken", array("token_type", "valid_until", "user_id", "token"))
+        ->addRow("download", $validUntil, $this->user->getId(), $token)
+        ->returning("uid")
+        ->execute();
+
+      $this->success = ($res !== false);
+      $this->lastError = $sql->getLastError();
+      if (!$this->success) {
+        return false;
+      }
+
+      $tokenId = $sql->getLastInsertId();
+      $query = $sql->insert("UserFileTokenFile", array("token_id", "file_id"));
+      foreach ($fileIds as $fileId) {
+        $query->addRow($tokenId, $fileId);
+      }
+
+      $res = $query->execute();
+      $this->success = ($res !== false);
+      $this->lastError = $sql->getLastError();
+
+      if ($this->success) {
+        $this->result["token"] = $token;
+      }
+
+      return $this->success;
+    }
+  }
+}

+ 65 - 0
core/Api/PatchSQL.class.php

@@ -0,0 +1,65 @@
+<?php
+
+namespace Api;
+
+use Api\Parameter\StringType;
+use Configuration\DatabaseScript;
+use Objects\User;
+
+class PatchSQL extends Request {
+
+  public function __construct(User $user, bool $externalCall = false) {
+    parent::__construct($user, $externalCall, array(
+      "className" => new StringType("className", 64)
+    ));
+    $this->loginRequired = true;
+    $this->csrfTokenRequired = false;
+  }
+
+  public function execute($values = array()) {
+    if (!parent::execute($values)) {
+      return false;
+    }
+
+    $className = $this->getParam("className");
+    $fullClassName = "\\Configuration\\Patch\\" . $className;
+    $path = getClassPath($fullClassName, true);
+    if (!file_exists($path)) {
+      return $this->createError("File not found");
+    }
+
+    if(!class_exists($fullClassName)) {
+      return $this->createError("Class not found.");
+    }
+
+    try {
+      $reflection = new \ReflectionClass($fullClassName);
+      if (!$reflection->isInstantiable()) {
+        return $this->createError("Class is not instantiable");
+      }
+
+      if (!$reflection->isSubclassOf(DatabaseScript::class)) {
+        return $this->createError("Not a database script.");
+      }
+
+      $sql = $this->user->getSQL();
+      $obj = $reflection->newInstance();
+      $queries = $obj->createQueries($sql);
+      if (!is_array($queries)) {
+        return $this->createError("Database script returned invalid values");
+      }
+
+      foreach($queries as $query) {
+        if (!$query->execute()) {
+          return $this->createError("Query error: " . $sql->getLastError());
+        }
+      }
+
+      $this->success = true;
+    } catch (\ReflectionException $e) {
+      return $this->createError("Error reflecting class: " . $e->getMessage());
+    }
+
+    return $this->success;
+  }
+}

+ 1 - 0
core/Api/Request.class.php

@@ -187,6 +187,7 @@ class Request {
   public function success() { return $this->success; }
   public function loginRequired() { return $this->loginRequired; }
   public function isExternalCall() { return $this->externalCall; }
+  public function clearError() { $this->success = true; $this->lastError = ""; }
 
   private function getMethod() {
     $class = str_replace("\\", "/", get_class($this));

+ 1 - 1
core/Api/SettingsAPI.class.php

@@ -39,7 +39,7 @@ namespace Api\Settings {
 
        $query = $sql->select("name", "value") ->from("Settings");
 
-       if (!is_null($key) && !empty($key)) {
+       if (!is_null($key)) {
          $query->where(new CondRegex(new Column("name"), $key));
        }
 

+ 23 - 2
core/Configuration/CreateDatabase.class.php

@@ -6,7 +6,7 @@ use Driver\SQL\SQL;
 use \Driver\SQL\Strategy\SetNullStrategy;
 use \Driver\SQL\Strategy\CascadeStrategy;
 
-class CreateDatabase {
+class CreateDatabase extends DatabaseScript {
 
   // NOTE:
   // explicit serial ids removed due to postgres' serial implementation
@@ -192,7 +192,10 @@ class CreateDatabase {
       ->addRow("User/edit", array(USER_GROUP_ADMIN), "Allows users to edit details and group memberships of any user")
       ->addRow("User/delete", array(USER_GROUP_ADMIN), "Allows users to delete any other user")
       ->addRow("Permission/fetch", array(USER_GROUP_ADMIN), "Allows users to list all API permissions")
-      ->addRow("Visitors/stats", array(USER_GROUP_ADMIN, USER_GROUP_SUPPORT), "Allows users to see visitor statistics");
+      ->addRow("Visitors/stats", array(USER_GROUP_ADMIN, USER_GROUP_SUPPORT), "Allows users to see visitor statistics")
+      ->addRow("PatchSQL", array(USER_GROUP_ADMIN), "Allows users to import database patches");
+
+    self::loadPatches($queries, $sql);
 
     return $queries;
   }
@@ -225,4 +228,22 @@ class CreateDatabase {
       "Best Regards<br>" .
       "{{site_name}} Administration";
   }
+
+  private static function loadPatches(&$queries, $sql) {
+    $patchDirectory = './core/Configuration/Patch/';
+    if (file_exists($patchDirectory) && is_dir($patchDirectory)) {
+      $scan_arr = scandir($patchDirectory);
+      $files_arr = array_diff($scan_arr, array('.','..'));
+      foreach ($files_arr as $file) {
+        $suffix = ".class.php";
+        if (endsWith($file, $suffix)) {
+          $className = substr($file, 0, strlen($file) - strlen($suffix));
+          $className = "\\Configuration\\Patch\\$className";
+          $method = "$className::createQueries";
+          $patchQueries = call_user_func($method, $sql);
+          foreach($patchQueries as $query) $queries[] = $query;
+        }
+      }
+    }
+  }
 }

+ 9 - 0
core/Configuration/DatabaseScript.class.php

@@ -0,0 +1,9 @@
+<?php
+
+namespace Configuration;
+
+use Driver\SQL\SQL;
+
+abstract class DatabaseScript {
+  public static abstract function createQueries(SQL $sql);
+}

+ 72 - 0
core/Configuration/Patch/file_api.class.php

@@ -0,0 +1,72 @@
+<?php
+
+namespace Configuration\Patch;
+
+use Configuration\DatabaseScript;
+use Driver\SQL\SQL;
+use Driver\SQL\Column\Column;
+use Driver\SQL\Strategy\CascadeStrategy;
+use Driver\SQL\Strategy\UpdateStrategy;
+
+class file_api extends DatabaseScript {
+
+  public static function createQueries(SQL $sql) {
+
+    $queries = array();
+
+    $queries[] = $sql->insert("ApiPermission", array("method", "groups", "description"))
+      ->onDuplicateKeyStrategy(new UpdateStrategy(array("method"), array("method" => new Column("method"))))
+      ->addRow("File/Download", array(), "Allows users to download files when logged in, or using a given token")
+      ->addRow("File/Upload", array(), "Allows users to upload files when logged in, or using a given token")
+      ->addRow("File/ValidateToken", array(), "Allows users to validate a given token")
+      ->addRow("File/RevokeToken", array(USER_GROUP_ADMIN), "Allows users to revoke a token")
+      ->addRow("File/ListFiles", array(), "Allows users to list all files assigned to an account")
+      ->addRow("File/ListTokens", array(USER_GROUP_ADMIN), "Allows users to list all tokens assigned to the virtual filesystem of an account")
+      ->addRow("File/CreateDirectory", array(), "Allows users to create a virtual directory")
+      ->addRow("File/Rename", array(), "Allows users to rename files in the virtual filesystem")
+      ->addRow("File/Move", array(), "Allows users to move files in the virtual filesystem")
+      ->addRow("File/Delete", array(), "Allows users to delete files in the virtual filesystem")
+      ->addRow("File/CreateUploadToken", array(USER_GROUP_ADMIN), "Allows users to create a token to upload files to the virtual filesystem assigned to the users account")
+      ->addRow("File/CreateDownloadToken", array(USER_GROUP_ADMIN), "Allows users to create a token to download files from the virtual filesystem assigned to the users account");
+
+    $queries[] = $sql->, array("request", "action", "target", "extra"))
+      ->onDuplicateKeyStrategy(new UpdateStrategy(array("request"), array("request" => new Column("request"))))
+      ->addRow("^/files(/.*)?$", "dynamic", "\\Documents\\Files", NULL);
+
+    $queries[] = $sql->createTable("UserFile")
+      ->onlyIfNotExists()
+      ->addSerial("uid")
+      ->addBool("directory")
+      ->addString("name", 64, false)
+      ->addString("path", 512, true)
+      ->addInt("parent_id", true)
+      ->addInt("user_id", true)
+      ->primaryKey("uid")
+      ->unique("parent_id", "name")
+      ->foreignKey("parent_id", "UserFile", "uid", new CascadeStrategy())
+      ->foreignKey("user_id", "User", "uid", new CascadeStrategy());
+
+    $queries[] = $sql->createTable("UserFileToken")
+      ->onlyIfNotExists()
+      ->addSerial("uid")
+      ->addString("token", 36, false)
+      ->addDateTime("valid_until", true)
+      ->addEnum("token_type", array("download", "upload"))
+      ->addInt("user_id")
+      # upload only:
+      ->addInt("maxFiles", true)
+      ->addInt("maxSize", true)
+      ->addString("extensions", 64, true)
+      ->primaryKey("uid")
+      ->foreignKey("user_id", "User", "uid");
+
+    $queries[] = $sql->createTable("UserFileTokenFile")
+      ->addInt("file_id")
+      ->addInt("token_id")
+      ->unique("file_id", "token_id")
+      ->foreignKey("file_id", "UserFile", "uid")
+      ->foreignKey("token_id", "UserFileToken", "uid");
+
+    return $queries;
+  }
+}

+ 61 - 0
core/Documents/Files.class.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace Documents {
+
+  use Documents\Files\FilesBody;
+  use Documents\Files\FilesHead;
+  use Elements\Document;
+  use Objects\User;
+
+  class Files extends Document {
+    public function __construct(User $user, string $view = NULL) {
+      parent::__construct($user, FilesHead::class, FilesBody::class, $view);
+    }
+  }
+}
+
+namespace Documents\Files {
+
+  use Elements\Head;
+  use Elements\SimpleBody;
+
+  class FilesHead extends Head {
+
+    protected function initSources() {
+      $this->loadBootstrap();
+      $this->loadFontawesome();
+    }
+
+    protected function initMetas() {
+      return array(
+        array('name' => 'viewport', 'content' => 'width=device-width, initial-scale=1.0'),
+        array('name' => 'format-detection', 'content' => 'telephone=yes'),
+        array('charset' => 'utf-8'),
+        array("http-equiv" => 'expires', 'content' => '0'),
+        array("name" => 'robots', 'content' => 'noarchive')
+      );
+    }
+
+    protected function initRawFields() {
+      return array();
+    }
+
+    protected function initTitle() {
+      return "File Control Panel";
+    }
+  }
+
+  class FilesBody extends SimpleBody {
+
+    public function __construct($document) {
+      parent::__construct($document);
+    }
+
+    protected function getContent() {
+      $html = "<noscript>" . $this->createErrorText("Javascript is required for this site to render.") . "</noscript>";
+      $html .= "<div id=\"root\"></div>";
+      return $html;
+    }
+  }
+
+}

+ 7 - 0
core/Elements/EmptyBody.class.php

@@ -0,0 +1,7 @@
+<?php
+
+use Elements\Body;
+
+class EmptyBody extends Body {
+
+}

+ 26 - 0
core/Elements/EmptyHead.class.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Elements;
+
+class EmptyHead extends Head {
+
+  public function __construct($document) {
+    parent::__construct($document);
+  }
+
+  protected function initSources() {
+  }
+
+  protected function initMetas() {
+    return array(
+    );
+  }
+
+  protected function initRawFields() {
+    return array();
+  }
+
+  protected function initTitle() {
+    return "";
+  }
+}

+ 84 - 8
core/Elements/View.class.php

@@ -2,8 +2,6 @@
 
 namespace Elements;
 
-use External\PHPMailer\Exception;
-
 abstract class View extends StaticView {
 
   private Document $document;
@@ -81,9 +79,10 @@ abstract class View extends StaticView {
     return $this->createList($items, "ul");
   }
 
-  protected function createLink($link, $title=null) {
+  protected function createLink($link, $title=null, $classes="") {
     if(is_null($title)) $title=$link;
-    return "<a href=\"$link\">$title</a>";
+    if(!empty($classes)) $classes = " class=\"$classes\"";
+    return "<a href=\"$link\"$classes>$title</a>";
   }
 
   protected function createExternalLink($link, $title=null) {
@@ -123,14 +122,91 @@ abstract class View extends StaticView {
     return $this->createStatusText("info", $text, $id, $hidden);
   }
 
-  protected function createStatusText($type, $text, $id="", $hidden=false) {
+  protected function createStatusText($type, $text, $id="", $hidden=false, $classes="") {
     if(strlen($id) > 0) $id = " id=\"$id\"";
-    $hidden = ($hidden?" hidden" : "");
-    return "<div class=\"alert alert-$type$hidden\" role=\"alert\"$id>$text</div>";
+    if($hidden) $classes .= " hidden";
+    if(strlen($classes) > 0) $classes = " $classes";
+    return "<div class=\"alert alert-$type$hidden$classes\" role=\"alert\"$id>$text</div>";
   }
 
   protected function createBadge($type, $text) {
     $text = htmlspecialchars($text);
     return "<span class=\"badge badge-$type\">$text</span>";
   }
-}
+
+  protected function createJumbotron(string $content, bool $fluid=false, $class="") {
+    $jumbotronClass = "jumbotron" . ($fluid ? "-fluid" : "");
+    if (!empty($class)) $jumbotronClass .= " $class";
+
+    return "
+      <div class=\"row\">
+        <div class=\"col-12\">
+          <div class=\"$jumbotronClass\">
+            $content
+          </div>
+        </div>
+      </div>";
+  }
+
+  public function createSimpleParagraph(string $content, string $class="") {
+    if($class) $class = " class=\"$class\"";
+    return "<p$class>$content</p>";
+  }
+
+  public function createParagraph($title, $id, $content) {
+    $id = replaceCssSelector($id);
+    $iconId = urlencode("$id-icon");
+    return "
+      <div class=\"row mt-4\">
+        <div class=\"col-12\">
+          <h2 id=\"$id\" data-target=\"$iconId\" class=\"inlineLink\">$title</h2>
+          <hr/>
+          $content
+        </div>
+      </div>";
+  }
+
+  protected function createBootstrapTable($data, string $classes="") {
+    $classes = empty($classes) ? "" : " $classes";
+    $code = "<div class=\"container$classes\">";
+    foreach($data as $row) {
+      $code .= "<div class=\"row mt-2 mb-2\">";
+      $columnCount = count($row);
+      if($columnCount > 0) {
+        $remainingSize = 12;
+        $columnSize = 12 / $columnCount;
+        foreach($row as $col) {
+          $size = ($columnSize <= $remainingSize ? $columnSize : $remainingSize);
+          $content = $col;
+          $class = "";
+          $code .= "<div";
+
+          if(is_array($col)) {
+            $content = "";
+            foreach($col as $key => $val) {
+              if(strcmp($key, "content") === 0) {
+                $content = $val;
+              } else if(strcmp($key, "class") === 0) {
+                $class = " " . $col["class"];
+              } else if(strcmp($key, "cols") === 0 && is_numeric($val)) {
+                $size = intval($val);
+              } else {
+                $code .= " $key=\"$val\"";
+              }
+            }
+
+            if(isset($col["class"])) $class = " " . $col["class"];
+          }
+
+          if($size <= 6) $class .= " col-md-" . intval($size * 2);
+          $code .= " class=\"col-lg-$size$class\">$content</div>";
+          $remainingSize -= $size;
+        }
+      }
+      $code .= "</div>";
+    }
+
+    $code .= "</div>";
+    return $code;
+  }
+}

+ 6 - 3
core/core.php

@@ -1,6 +1,6 @@
 <?php
 
-define("WEBBASE_VERSION", "1.0.4");
+define("WEBBASE_VERSION", "1.1.0");
 
 function getProtocol() {
   return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https" : "http";
@@ -75,6 +75,10 @@ function replaceCssSelector($sel) {
   return preg_replace("~[.#<>]~", "_", preg_replace("~[:\-]~", "", $sel));
 }
 
+function urlId($str) {
+  return urlencode(htmlspecialchars(preg_replace("[: ]","-", $str)));
+}
+
 function getClassPath($class, $suffix = true) {
   $path = str_replace('\\', '/', $class);
   $path = array_values(array_filter(explode("/", $path)));
@@ -134,7 +138,6 @@ function serveStatic(string $webRoot, string $file) {
     $length = $size;
 
     if (isset($_SERVER['HTTP_RANGE'])) {
-      $partialContent = true;
       preg_match('/bytes=(\d+)-(\d+)?/', $_SERVER['HTTP_RANGE'], $matches);
       $offset = intval($matches[1]);
       $length = intval($matches[2]) - $offset;
@@ -166,4 +169,4 @@ function parseClass($class) {
   $parts = explode("\\", $class);
   $parts = array_map('ucfirst', $parts);
   return implode("\\", $parts);
-}
+}

+ 1 - 0
files/uploaded/.htaccess

@@ -0,0 +1 @@
+DENY FROM ALL

+ 4 - 2
js/account.js

@@ -27,6 +27,8 @@ $(document).ready(function () {
     }
 
     // Login
+    $("#username").keypress(function (e) { if(e.which == 13) $("#password").focus(); });
+    $("#password").keypress(function (e) { if(e.which == 13) $("#btnLogin").click(); });
     $("#btnLogin").click(function() {
         const username = $("#username").val();
         const password = $("#password").val();
@@ -45,7 +47,7 @@ $(document).ready(function () {
                 btn.prop("disabled", false);
                 $("#password").val("");
                 createdDiv.hide();
-                showAlert(res.msg);
+                showAlert("danger", res.msg);
             }
         });
     });
@@ -172,4 +174,4 @@ $(document).ready(function () {
             });
         }
     });
-});
+});