summaryrefslogtreecommitdiffstats
path: root/application/controllers
diff options
context:
space:
mode:
Diffstat (limited to 'application/controllers')
-rw-r--r--application/controllers/Welcome.php25
-rw-r--r--application/controllers/api.php66
-rw-r--r--application/controllers/api/api_controller.php14
-rw-r--r--application/controllers/api/v2/api_info.php16
-rw-r--r--application/controllers/api/v2/file.php96
-rw-r--r--application/controllers/api/v2/user.php75
-rw-r--r--application/controllers/file/file_default.php989
-rw-r--r--application/controllers/file/multipaste.php113
-rw-r--r--application/controllers/tools.php130
-rw-r--r--application/controllers/user.php705
10 files changed, 2204 insertions, 25 deletions
diff --git a/application/controllers/Welcome.php b/application/controllers/Welcome.php
deleted file mode 100644
index 9213c0cf5..000000000
--- a/application/controllers/Welcome.php
+++ /dev/null
@@ -1,25 +0,0 @@
-<?php
-defined('BASEPATH') OR exit('No direct script access allowed');
-
-class Welcome extends CI_Controller {
-
- /**
- * Index Page for this controller.
- *
- * Maps to the following URL
- * http://example.com/index.php/welcome
- * - or -
- * http://example.com/index.php/welcome/index
- * - or -
- * Since this controller is set as the default controller in
- * config/routes.php, it's displayed at http://example.com/
- *
- * So any other public methods not prefixed with an underscore will
- * map to /index.php/welcome/<method_name>
- * @see https://codeigniter.com/user_guide/general/urls.html
- */
- public function index()
- {
- $this->load->view('welcome_message');
- }
-}
diff --git a/application/controllers/api.php b/application/controllers/api.php
new file mode 100644
index 000000000..9540f1ff7
--- /dev/null
+++ b/application/controllers/api.php
@@ -0,0 +1,66 @@
+<?php
+/*
+ * Copyright 2014 Florian "Bluewind" Pritz <bluewind@server-speed.net>
+ *
+ * Licensed under AGPLv3
+ * (see COPYING for full license text)
+ *
+ */
+
+class Api extends MY_Controller {
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->load->model('mfile');
+ $this->load->model('mmultipaste');
+ }
+
+ public function route() {
+ try {
+ $requested_version = $this->uri->segment(2);
+ $controller = $this->uri->segment(3);
+ $function = $this->uri->segment(4);
+
+ if (!preg_match("/^v([0-9]+)(.[0-9]+){0,2}$/", $requested_version)) {
+ throw new \exceptions\PublicApiException("api/invalid-version", "Invalid API version requested");
+ }
+
+ $requested_version = substr($requested_version, 1);
+
+ $major = intval(explode(".", $requested_version)[0]);
+
+ if (!preg_match("/^[a-zA-Z-_]+$/", $controller)) {
+ throw new \exceptions\PublicApiException("api/invalid-endpoint", "Invalid endpoint requested");
+ }
+
+ if (!preg_match("/^[a-zA-Z-_]+$/", $function)) {
+ throw new \exceptions\PublicApiException("api/invalid-endpoint", "Invalid endpoint requested");
+ }
+
+ $namespace = "controllers\\api\\v".$major;
+ $class = $namespace."\\".$controller;
+ $class_info = $namespace."\\api_info";
+
+ if (!class_exists($class_info) || version_compare($class_info::get_version(), $requested_version, "<")) {
+ throw new \exceptions\PublicApiException("api/version-not-supported", "Requested API version is not supported");
+ }
+
+ if (!class_exists($class)) {
+ throw new \exceptions\PublicApiException("api/unknown-endpoint", "Unknown endpoint requested");
+ }
+
+ $c= new $class;
+ if (!method_exists($c, $function)) {
+ throw new \exceptions\PublicApiException("api/unknown-endpoint", "Unknown endpoint requested");
+ }
+ return send_json_reply($c->$function());
+ } catch (\exceptions\PublicApiException $e) {
+ return send_json_error_reply($e->get_error_id(), $e->getMessage(), $e->get_data());
+ } catch (\Exception $e) {
+ \libraries\ExceptionHandler::log_exception($e);
+ return send_json_error_reply("internal-error", "An unhandled internal server error occured");
+ }
+ }
+}
diff --git a/application/controllers/api/api_controller.php b/application/controllers/api/api_controller.php
new file mode 100644
index 000000000..2b9054b17
--- /dev/null
+++ b/application/controllers/api/api_controller.php
@@ -0,0 +1,14 @@
+<?php
+/*
+ * Copyright 2014 Florian "Bluewind" Pritz <bluewind@server-speed.net>
+ *
+ * Licensed under AGPLv3
+ * (see COPYING for full license text)
+ *
+ */
+
+namespace controllers\api;
+
+abstract class api_controller extends \CI_Controller {
+}
+
diff --git a/application/controllers/api/v2/api_info.php b/application/controllers/api/v2/api_info.php
new file mode 100644
index 000000000..8d2bdf6dc
--- /dev/null
+++ b/application/controllers/api/v2/api_info.php
@@ -0,0 +1,16 @@
+<?php
+/*
+ * Copyright 2014-2015 Florian "Bluewind" Pritz <bluewind@server-speed.net>
+ *
+ * Licensed under AGPLv3
+ * (see COPYING for full license text)
+ *
+ */
+namespace controllers\api\v2;
+
+class api_info extends \controllers\api\api_controller {
+ static public function get_version()
+ {
+ return "2.1.1";
+ }
+}
diff --git a/application/controllers/api/v2/file.php b/application/controllers/api/v2/file.php
new file mode 100644
index 000000000..15a43fc45
--- /dev/null
+++ b/application/controllers/api/v2/file.php
@@ -0,0 +1,96 @@
+<?php
+/*
+ * Copyright 2014-2015 Florian "Bluewind" Pritz <bluewind@server-speed.net>
+ *
+ * Licensed under AGPLv3
+ * (see COPYING for full license text)
+ *
+ */
+namespace controllers\api\v2;
+
+class file extends \controllers\api\api_controller {
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->load->model('mfile');
+ $this->load->model('mmultipaste');
+ }
+
+ public function upload()
+ {
+ $this->muser->require_access("basic");
+
+ $files = getNormalizedFILES();
+
+ if (empty($files)) {
+ throw new \exceptions\PublicApiException("file/no-file", "No file was uploaded or unknown error occurred.");
+ }
+
+ \service\files::verify_uploaded_files($files);
+
+ $limits = $this->muser->get_upload_id_limits();
+ $userid = $this->muser->get_userid();
+ $urls = array();
+
+ foreach ($files as $file) {
+ $id = $this->mfile->new_id($limits[0], $limits[1]);
+ \service\files::add_uploaded_file($userid, $id, $file["tmp_name"], $file["name"]);
+ $ids[] = $id;
+ $urls[] = site_url($id).'/';
+ }
+
+ return array(
+ "ids" => $ids,
+ "urls" => $urls,
+ );
+ }
+
+ public function get_config()
+ {
+ return array(
+ "upload_max_size" => $this->config->item("upload_max_size"),
+ "max_files_per_request" => intval(ini_get("max_file_uploads")),
+ "max_input_vars" => intval(ini_get("max_input_vars")),
+ "request_max_size" => return_bytes(ini_get("post_max_size")),
+ );
+ }
+
+ public function history()
+ {
+ $this->muser->require_access("apikey");
+ $history = \service\files::history($this->muser->get_userid());
+ foreach ($history['multipaste_items'] as $key => $item) {
+ foreach ($item['items'] as $inner_key => $item) {
+ unset($history['multipaste_items'][$key]['items'][$inner_key]['sort_order']);
+ }
+ }
+
+ $history = ensure_json_keys_contain_objects($history, array("items", "multipaste_items"));
+
+ return $history;
+ }
+
+ public function delete()
+ {
+ $this->muser->require_access("apikey");
+ $ids = $this->input->post_array("ids");
+ $ret = \service\files::delete($ids);
+
+ $ret = ensure_json_keys_contain_objects($ret, array("errors", "deleted"));
+
+ return $ret;
+ }
+
+ public function create_multipaste()
+ {
+ $this->muser->require_access("basic");
+ $ids = $this->input->post_array("ids");
+ $userid = $this->muser->get_userid();
+ $limits = $this->muser->get_upload_id_limits();
+
+ return \service\files::create_multipaste($ids, $userid, $limits);
+ }
+
+}
+# vim: set noet:
diff --git a/application/controllers/api/v2/user.php b/application/controllers/api/v2/user.php
new file mode 100644
index 000000000..655dc62f3
--- /dev/null
+++ b/application/controllers/api/v2/user.php
@@ -0,0 +1,75 @@
+<?php
+/*
+ * Copyright 2014-2016 Florian "Bluewind" Pritz <bluewind@server-speed.net>
+ *
+ * Licensed under AGPLv3
+ * (see COPYING for full license text)
+ *
+ */
+namespace controllers\api\v2;
+
+class user extends \controllers\api\api_controller {
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->load->model('muser');
+ }
+
+ public function apikeys()
+ {
+ $this->muser->require_access("full");
+ return \service\user::apikeys($this->muser->get_userid());
+ }
+
+ public function create_apikey()
+ {
+ $username = $this->input->post("username");
+ $password = $this->input->post("password");
+ if ($username && $password) {
+ if (!$this->muser->login($username, $password)) {
+ throw new \exceptions\NotAuthenticatedException("user/login-failed", "Login failed");
+ }
+ }
+
+ $this->muser->require_access("full");
+
+ $userid = $this->muser->get_userid();
+ $comment = $this->input->post("comment");
+ $comment = $comment === false ? "" : $comment;
+ $access_level = $this->input->post("access_level");
+
+ $key = \service\user::create_apikey($userid, $comment, $access_level);
+
+ return array(
+ "new_key" => $key,
+ );
+ }
+
+ public function delete_apikey()
+ {
+ $this->muser->require_access("full");
+
+ $userid = $this->muser->get_userid();
+ $key = $this->input->post("delete_key");
+
+ $this->db->where('user', $userid)
+ ->where('key', $key)
+ ->delete('apikeys');
+
+ $affected = $this->db->affected_rows();
+
+ assert($affected >= 0 && $affected <= 1);
+ if ($affected == 1) {
+ return array(
+ "deleted_keys" => array(
+ $key => array (
+ "key" => $key,
+ ),
+ ),
+ );
+ } else {
+ throw new \exceptions\PublicApiException('user/delete_apikey/failed', 'Apikey deletion failed. Possibly wrong owner.');
+ }
+ }
+}
diff --git a/application/controllers/file/file_default.php b/application/controllers/file/file_default.php
new file mode 100644
index 000000000..9ef7d0880
--- /dev/null
+++ b/application/controllers/file/file_default.php
@@ -0,0 +1,989 @@
+<?php
+/*
+ * Copyright 2009-2013 Florian "Bluewind" Pritz <bluewind@server-speed.net>
+ *
+ * Licensed under AGPLv3
+ * (see COPYING for full license text)
+ *
+ */
+
+class File_default extends MY_Controller {
+
+ function __construct()
+ {
+ parent::__construct();
+
+ $this->load->model('mfile');
+ $this->load->model('mmultipaste');
+ }
+
+ function index()
+ {
+ if ($this->input->is_cli_request()) {
+ $this->load->library("../controllers/tools");
+ return $this->tools->index();
+ }
+
+ // Try to guess what the user would like to do.
+ $id = $this->uri->segment(1);
+ if (strpos($id, "m-") === 0 && $this->mmultipaste->id_exists($id)) {
+ $this->_download();
+ } elseif ($id != "file" && $this->mfile->id_exists($id)) {
+ $this->_download();
+ } elseif ($id && $id != "file") {
+ $this->_non_existent();
+ } else {
+ $this->upload_form();
+ }
+ }
+
+ /**
+ * Generate a page title of the format "Multipaste - $filename, $filename, … (N more)".
+ * This mainly helps in IRC channels to quickly determine what is in a multipaste.
+ *
+ * @param files array of filedata
+ * @return title to be used
+ */
+ private function _multipaste_page_title(array $files)
+ {
+ $filecount = count($files);
+ $title = "Multipaste ($filecount files) - ";
+ $titlenames = array();
+ $len = strlen($title);
+ $delimiter = ', ';
+ $maxlen = 100;
+
+ foreach ($files as $file) {
+ if ($len > $maxlen) break;
+
+ $filename = $file['filename'];
+ $titlenames[] = htmlspecialchars($filename);
+ $len += strlen($filename) + strlen($delimiter);
+ }
+
+ $title .= implode($delimiter, $titlenames);
+
+ $leftover_count = $filecount - count($titlenames);
+
+ if ($leftover_count > 0) {
+ $title .= $delimiter.'… ('.$leftover_count.' more)';
+ }
+
+ return $title;
+ }
+
+ function _download()
+ {
+ $id = $this->uri->segment(1);
+ $lexer = urldecode($this->uri->segment(2));
+
+ $is_multipaste = false;
+ if ($this->mmultipaste->id_exists($id)) {
+ $is_multipaste = true;
+
+ if(!$this->mmultipaste->valid_id($id)) {
+ $this->mmultipaste->delete_id($id);
+ return $this->_non_existent();
+ }
+ $files = $this->mmultipaste->get_files($id);
+ $this->data["title"] = $this->_multipaste_page_title($files);
+ } elseif ($this->mfile->id_exists($id)) {
+ if (!$this->mfile->valid_id($id)) {
+ return $this->_non_existent();
+ }
+
+ $files = array($this->mfile->get_filedata($id));
+ $this->data["title"] = htmlspecialchars($files[0]["filename"]);
+ } else {
+ assert(0);
+ }
+
+ assert($files !== false);
+ assert(is_array($files));
+ assert(count($files) >= 1);
+
+ // don't allow unowned files to be downloaded
+ foreach ($files as $filedata) {
+ if ($filedata["user"] == 0) {
+ return $this->_non_existent();
+ }
+ }
+
+ $etag = "";
+ foreach ($files as $filedata) {
+ $etag = sha1($etag.$filedata["data_id"]);
+ }
+
+ // handle some common "lexers" here
+ switch ($lexer) {
+ case "":
+ break;
+
+ case "qr":
+ handle_etag($etag);
+ header("Content-disposition: inline; filename=\"".$id."_qr.png\"\n");
+ header("Content-Type: image/png\n");
+ $qr = new \Endroid\QrCode\QrCode();
+ $qr->setText(site_url($id).'/')
+ ->setSize(350)
+ ->setErrorCorrection('low')
+ ->render();
+ exit();
+
+ case "info":
+ return $this->_display_info($id);
+
+ case "tar":
+ if ($is_multipaste) {
+ return $this->_tarball($id);
+ }
+
+ case "pls":
+ if ($is_multipaste) {
+ return $this->_generate_playlist($id);
+ }
+
+ default:
+ if ($is_multipaste) {
+ throw new \exceptions\UserInputException("file/download/invalid-action", "Invalid action \"".htmlspecialchars($lexer)."\"");
+ }
+ break;
+ }
+
+ $this->load->driver("ddownload");
+
+ // user wants the plain file
+ if ($lexer == 'plain') {
+ assert(count($files) == 1);
+ handle_etag($etag);
+
+ $filedata = $files[0];
+ $filepath = $this->mfile->file($filedata["data_id"]);
+ $this->ddownload->serveFile($filepath, $filedata["filename"], "text/plain");
+ exit();
+ }
+
+ $output_cache = new \libraries\Output_cache();
+
+ foreach ($files as $key => $filedata) {
+ $file = $this->mfile->file($filedata['data_id']);
+ $pygments = new \libraries\Pygments($file, $filedata["mimetype"], $filedata["filename"]);
+
+ // autodetect the lexer for highlighting if the URL contains a / after the ID (/ID/)
+ // /ID/lexer disables autodetection
+ $autodetect_lexer = !$lexer && substr_count(ltrim($this->uri->uri_string(), "/"), '/') >= 1;
+ $autodetect_lexer = $is_multipaste ? true : $autodetect_lexer;
+ if ($autodetect_lexer) {
+ $lexer = $pygments->autodetect_lexer();
+ }
+
+ // resolve aliases
+ // this is mainly used for compatibility
+ $lexer = $pygments->resolve_lexer_alias($lexer);
+
+ // if there is no mimetype mapping we can't highlight it
+ $can_highlight = $pygments->can_highlight();
+
+ $filesize_too_big = filesize($file) > $this->config->item('upload_max_text_size');
+
+ if (!$can_highlight || $filesize_too_big || !$lexer) {
+ if (!$is_multipaste) {
+ // prevent javascript from being executed and forbid frames
+ // this should allow us to serve user submitted HTML content without huge security risks
+ foreach (array("X-WebKit-CSP", "X-Content-Security-Policy", "Content-Security-Policy") as $header_name) {
+ header("$header_name: default-src 'none'; img-src *; media-src *; font-src *; style-src 'unsafe-inline' *; script-src 'none'; object-src *; frame-src 'none'; ");
+ }
+ handle_etag($etag);
+ $this->ddownload->serveFile($file, $filedata["filename"], $filedata["mimetype"]);
+ exit();
+ } else {
+ $mimetype = $filedata["mimetype"];
+ $base = explode("/", $filedata["mimetype"])[0];
+
+ if (\libraries\Image::type_supported($mimetype)) {
+ $filedata["tooltip"] = \service\files::tooltip($filedata);
+ $filedata["orientation"] = libraries\Image::get_exif_orientation($file);
+ $output_cache->add_merge(
+ array("items" => array($filedata)),
+ 'file/fragments/thumbnail'
+ );
+ } else if ($base == "audio") {
+ $output_cache->add(array("filedata" => $filedata), "file/fragments/audio-player");
+ } else if ($base == "video") {
+ $output_cache->add(array("filedata" => $filedata), "file/fragments/video-player");
+ } else {
+ $output_cache->add_merge(
+ array("items" => array($filedata)),
+ 'file/fragments/uploads_table'
+ );
+ }
+ continue;
+ }
+ }
+
+ if ($lexer == "asciinema") {
+ $output_cache->add(array("filedata" => $filedata), "file/fragments/asciinema-player");
+ } else {
+ $output_cache->add_function(function() use ($output_cache, $filedata, $lexer, $is_multipaste) {
+ $renderer = new \service\renderer($output_cache, $this->mfile, $this->data);
+ $renderer->highlight_file($filedata, $lexer, $is_multipaste);
+ });
+ }
+ }
+
+ // TODO: move lexers json to dedicated URL
+ $this->data['lexers'] = \libraries\Pygments::get_lexers();
+
+ // Output everything
+ // Don't use the output class/append_output because it does too
+ // much magic ({elapsed_time} and {memory_usage}).
+ // Direct echo puts us on the safe side.
+ echo $this->load->view('file/html_header', $this->data, true);
+ $output_cache->render();
+ echo $this->load->view('file/html_footer', $this->data, true);
+ }
+
+ private function _display_info($id)
+ {
+ if ($this->mmultipaste->id_exists($id)) {
+ $files = $this->mmultipaste->get_files($id);
+
+ $this->data["title"] .= " - Info $id";
+
+ $multipaste = $this->mmultipaste->get_multipaste($id);
+ $total_size = 0;
+ $timeout = -1;
+ foreach($files as $filedata) {
+ $total_size += $filedata["filesize"];
+ $file_timeout = $this->mfile->get_timeout($filedata["id"]);
+ if ($timeout == -1 || ($timeout > $file_timeout && $file_timeout >= 0)) {
+ $timeout = $file_timeout;
+ }
+ }
+
+ $data = array_merge($this->data, array(
+ 'timeout_string' => $timeout >= 0 ? date("r", $timeout) : "Never",
+ 'upload_date' => $multipaste["date"],
+ 'id' => $id,
+ 'size' => $total_size,
+ 'file_count' => count($files),
+ ));
+
+ $this->load->view('header', $this->data);
+ $this->load->view('file/multipaste_info', $data);
+ $this->load->view('footer', $this->data);
+ return;
+ } elseif ($this->mfile->id_exists($id)) {
+ $this->data["title"] .= " - Info $id";
+ $this->data["filedata"] = $this->mfile->get_filedata($id);
+ $this->data["id"] = $id;
+ $this->data['timeout'] = $this->mfile->get_timeout_string($id);
+
+ $this->load->view('header', $this->data);
+ $this->load->view('file/file_info', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+ }
+
+ private function _tarball($id)
+ {
+ if ($this->mmultipaste->id_exists($id)) {
+ $seen = array();
+ $path = $this->mmultipaste->get_tarball_path($id);
+ $archive = new \service\storage($path);
+
+ if (!$archive->exists()) {
+ $files = $this->mmultipaste->get_files($id);
+
+ $total_size = 0;
+ foreach ($files as $filedata) {
+ $total_size += $filedata["filesize"];
+ }
+
+ if ($total_size > $this->config->item("tarball_max_size")) {
+ throw new \exceptions\PublicApiException("file/tarball/tarball-filesize-limit", "Tarball too large, refusing to create.");
+ }
+
+ $tmpfile = $archive->begin();
+ // create empty tar archive so PharData has something to open
+ file_put_contents($tmpfile, str_repeat("\0", 1024*10));
+ $a = new PharData($tmpfile);
+
+ foreach ($files as $filedata) {
+ $filename = $filedata["filename"];
+ if (isset($seen[$filename]) && $seen[$filename]) {
+ $filename = $filedata["id"]."-".$filedata["filename"];
+ }
+ assert(!isset($seen[$filename]));
+ $a->addFile($this->mfile->file($filedata["data_id"]), $filename);
+ $seen[$filename] = true;
+ }
+ $archive->gzip_compress();
+ $archive->commit();
+ }
+
+ // update mtime so the cronjob will keep the file for longer
+ $lock = fopen($archive->get_file(), "r+");
+ flock($lock, LOCK_SH);
+ touch($archive->get_file());
+ flock($lock, LOCK_UN);
+
+ assert(filesize($archive->get_file()) > 0);
+
+ $this->load->driver("ddownload");
+ $this->ddownload->serveFile($archive->get_file(), "$id.tar.gz", "application/x-gzip");
+ }
+ }
+
+ /**
+ * Generate a PLS v2 playlist
+ */
+ private function _generate_playlist($id)
+ {
+ $files = $this->mmultipaste->get_files($id);
+ $counter = 1;
+
+ $playlist = "[playlist]\n";
+ foreach ($files as $file) {
+ // only add audio/video files
+ $base = explode("/", $file['mimetype'])[0];
+ if (!($base === "audio" || $base === "video")) {
+ continue;
+ }
+
+ $url = site_url($file["id"]);
+ $playlist .= sprintf("File%d=%s\n", $counter++, $url);
+ }
+ $playlist .= sprintf("NumberOfEntries=%d\n", $counter - 1);
+ $playlist .= "Version=2\n";
+
+ $this->output->set_content_type('audio/x-scpls');
+ $this->output->set_output($playlist);
+ }
+
+ function _non_existent()
+ {
+ $this->data["title"] .= " - Not Found";
+ $this->output->set_status_header(404);
+ $this->load->view('header', $this->data);
+ $this->load->view('file/non_existent', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ private function _prepare_claim($ids, $lexer)
+ {
+ if (!$this->muser->logged_in()) {
+ $this->muser->require_session();
+ // keep the upload but require the user to login
+ $last_upload = $this->session->userdata("last_upload");
+ if ($last_upload === false) {
+ $last_upload = array(
+ "ids" => [],
+ "lexer" => "",
+ );
+ }
+ $last_upload = array(
+ "ids" => array_merge($last_upload['ids'], $ids),
+ "lexer" => "",
+ );
+ $this->session->set_userdata("last_upload", $last_upload);
+ $this->data["redirect_uri"] = "file/claim_id";
+ $this->muser->require_access("basic");
+ }
+
+ }
+
+ function _show_url($ids, $lexer)
+ {
+ $redirect = false;
+ $this->_prepare_claim($ids, $lexer);
+
+ foreach ($ids as $id) {
+ if ($lexer) {
+ $this->data['urls'][] = site_url($id).'/'.$lexer;
+ } else {
+ $this->data['urls'][] = site_url($id).'/';
+
+ if (count($ids) == 1) {
+ $filedata = $this->mfile->get_filedata($id);
+ $file = $this->mfile->file($filedata['data_id']);
+ $pygments = new \libraries\Pygments($file, $filedata["mimetype"], $filedata["filename"]);
+ $lexer = $pygments->should_highlight();
+
+ // If we detected a highlightable file redirect,
+ // otherwise show the URL because browsers would just show a DL dialog
+ if ($lexer) {
+ $redirect = true;
+ }
+ }
+ }
+ }
+
+ if ($redirect && count($ids) == 1) {
+ redirect($this->data['urls'][0], "location", 303);
+ } else {
+ $this->load->view('header', $this->data);
+ $this->load->view('file/show_url', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+ }
+
+ function upload_form()
+ {
+ $this->data['title'] .= ' - Upload';
+ $this->data['small_upload_size'] = $this->config->item('small_upload_size');
+ $this->data['max_upload_size'] = $this->config->item('upload_max_size');
+ $this->data['upload_max_age'] = $this->config->item('upload_max_age')/60/60/24;
+
+ $this->data['username'] = $this->muser->get_username();
+
+ $repaste_id = $this->input->get("repaste");
+
+ if ($repaste_id) {
+ $filedata = $this->mfile->get_filedata($repaste_id);
+
+ $pygments = new \libraries\Pygments($this->mfile->file($filedata["data_id"]), $filedata["mimetype"], $filedata["filename"]);
+ if ($filedata !== false && $pygments->can_highlight()) {
+ $this->data["textarea_content"] = file_get_contents($this->mfile->file($filedata["data_id"]));
+ }
+ }
+
+ if (file_exists(FCPATH.'data/client/latest')) {
+ $this->var->latest_client = trim(file_get_contents(FCPATH.'data/client/latest'));
+ $this->data['client_link'] = base_url().'data/client/fb-'.$this->var->latest_client.'.tar.gz';
+ } else {
+ $this->data['client_link'] = false;
+ }
+
+ $this->load->view('header', $this->data);
+ $this->load->view('file/upload_form', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ // Allow CLI clients to query the server for the maxium filesize so they can
+ // stop the upload before wasting time and bandwith
+ function get_max_size()
+ {
+ echo $this->config->item('upload_max_size');
+ }
+
+ function thumbnail()
+ {
+ $id = $this->uri->segment(3);
+
+ if (!$this->mfile->valid_id($id)) {
+ return $this->_non_existent();
+ }
+
+ $etag = "$id-thumb";
+ handle_etag($etag);
+
+ $thumb_size = 150;
+ $cache_timeout = 60*60*24*30; # 1 month
+
+ $filedata = $this->mfile->get_filedata($id);
+ if (!$filedata) {
+ throw new \exceptions\ApiException("file/thumbnail/filedata-unavailable", "Failed to get file data");
+ }
+
+ $cache_key = $filedata['data_id'].'_thumb_'.$thumb_size;
+
+ $thumb = cache_function($cache_key, $cache_timeout, function() use ($filedata, $thumb_size){
+ $CI =& get_instance();
+ $img = new libraries\Image($this->mfile->file($filedata["data_id"]));
+ $img->makeThumb($thumb_size, $thumb_size);
+ $thumb = $img->get(IMAGETYPE_JPEG);
+ return $thumb;
+ });
+
+ $this->output->set_header("Cache-Control:max-age=31536000, public");
+ $this->output->set_header("Expires: ".date("r", time() + 365 * 24 * 60 * 60));
+ $this->output->set_content_type("image/jpeg");
+ $this->output->set_output($thumb);
+ }
+
+ function upload_history_thumbnails()
+ {
+ $this->muser->require_access();
+
+ $user = $this->muser->get_userid();
+
+ // TODO: move to \service\files and possibly use \s\f::history()
+ $query = $this->db
+ ->select('files.id, filename, mimetype, files.date, hash, file_storage.id storage_id, filesize, user')
+ ->from('files')
+ ->join('file_storage', 'file_storage.id = files.file_storage_id')
+ ->where('
+ (files.user = '.$this->db->escape($user).')
+ AND (
+ mimetype LIKE \'image%\'
+ OR mimetype IN (\'application/pdf\')
+ )', null, false)
+ ->order_by('date', 'desc')
+ ->get()->result_array();
+
+ foreach($query as $key => $item) {
+ assert($item["user"] === $user);
+ $item["data_id"] = $item['hash']."-".$item['storage_id'];
+ $query[$key]["data_id"] = $item["data_id"];
+ if (!$this->mfile->valid_filedata($item)) {
+ unset($query[$key]);
+ continue;
+ }
+ $query[$key]["tooltip"] = \service\files::tooltip($item);
+ $query[$key]["orientation"] = libraries\Image::get_exif_orientation($this->mfile->file($item["data_id"]));
+ }
+
+ $this->data["items"] = $query;
+
+ $this->load->view('header', $this->data);
+ $this->load->view('file/upload_history_thumbnails', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ public function handle_history_submit()
+ {
+ $this->muser->require_access("apikey");
+
+ $process = $this->input->post("process");
+
+ $dispatcher = [
+ "delete" => function() {
+ return $this->do_delete();
+ },
+ "multipaste" => function() {
+ return $this->_append_multipaste_queue();
+ },
+ ];
+
+ if (isset($dispatcher[$process])) {
+ $dispatcher[$process]();
+ } else {
+ throw new \exceptions\UserInputException("file/handle_history_submit/invalid-process-value", "Value in process field not found in dispatch table");
+ }
+ }
+
+ private function _append_multipaste_queue()
+ {
+ $ids = $this->input->post_array("ids");
+ if ($ids === false) {
+ $ids = [];
+ }
+
+ $m = new \service\multipaste_queue();
+ $m->append($ids);
+ redirect("file/multipaste/queue");
+ }
+
+ function upload_history()
+ {
+ $this->muser->require_access("apikey");
+
+ $history = service\files::history($this->muser->get_userid());
+
+ // key: database field name; value: display name
+ $fields = array(
+ "id" => "ID",
+ "filename" => "Filename",
+ "mimetype" => "Mimetype",
+ "date" => "Date",
+ "hash" => "Hash",
+ "filesize" => "Size"
+ );
+
+ $this->data['title'] .= ' - Upload history';
+ foreach($fields as $length_key => $value) {
+ $lengths[$length_key] = mb_strlen($value);
+ }
+
+ foreach ($history["multipaste_items"] as $key => $item) {
+ $size = 0;
+ $filenames = array();
+ $files = array();
+ $max_filenames = 10;
+
+ foreach ($item["items"] as $i) {
+ $size += $history["items"][$i["id"]]["filesize"];
+ $files[] = array(
+ "filename" => $history["items"][$i["id"]]['filename'],
+ "sort_order" => $i["sort_order"],
+ );
+ }
+
+ uasort($files, function ($a, $b) {
+ return $a['sort_order'] - $b['sort_order'];
+ });
+
+ $filenames = array_map(function ($a) {return $a['filename'];}, $files);
+
+ if (count($filenames) > $max_filenames) {
+ $filenames = array_slice($filenames, 0, $max_filenames);
+ $filenames[] = "...";
+ }
+
+ $history["items"][] = array(
+ "id" => $item["url_id"],
+ "filename" => count($item["items"])." file(s)",
+ "mimetype" => "",
+ "date" => $item["date"],
+ "hash" => "",
+ "filesize" => $size,
+ "preview_text" => implode("\n", $filenames),
+ );
+ }
+
+ uasort($history["items"], function($a, $b) {
+ return $b["date"] - $a["date"];
+ });
+
+ foreach($history["items"] as $key => $item) {
+ $history["items"][$key]["filesize"] = format_bytes($item["filesize"]);
+
+ if (isset($item['preview_text'])) {
+ $history["items"][$key]["preview_text"] = htmlentities($item['preview_text']);
+ }
+ }
+
+ $this->data["items"] = $history["items"];
+ $this->data["lengths"] = $lengths;
+ $this->data["fields"] = $fields;
+ $this->data["total_size"] = format_bytes($history["total_size"]);
+
+ $this->load->view('header', $this->data);
+ $this->load->view('file/upload_history', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ function do_delete()
+ {
+ $this->muser->require_access("apikey");
+
+ $ids = $this->input->post_array("ids");
+
+ $ret = \service\files::delete($ids);
+
+ $this->data["errors"] = $ret["errors"];
+ $this->data["deleted_count"] = $ret["deleted_count"];
+ $this->data["total_count"] = $ret["total_count"];
+
+ $this->load->view('header', $this->data);
+ $this->load->view('file/deleted', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ function do_multipaste()
+ {
+ $this->muser->require_access("basic");
+
+ $ids = $this->input->post_array("ids");
+ $userid = $this->muser->get_userid();
+ $limits = $this->muser->get_upload_id_limits();
+
+ $ret = \service\files::create_multipaste($ids, $userid, $limits);
+
+ return $this->_show_url(array($ret["url_id"]), false);
+ }
+
+ /**
+ * Handle submissions from the web form (files and textareas).
+ */
+ public function do_websubmit()
+ {
+ $files = getNormalizedFILES();
+ $contents = $this->input->post_array("content");
+ $filenames = $this->input->post_array("filename");
+
+ if (!is_array($filenames) || !is_array($contents)) {
+ throw new \exceptions\UserInputException('file/websubmit/invalid-form', 'The submitted POST form is invalid');
+ }
+
+ $ids = array();
+ $ids = array_merge($ids, $this->_handle_textarea($contents, $filenames));
+ $ids = array_merge($ids, $this->_handle_files($files));
+
+
+ if (empty($ids)) {
+ throw new \exceptions\UserInputException("file/websubmit/no-input", "You didn't enter any text or upload any files");
+ }
+
+ if (count($ids) > 1) {
+ $userid = $this->muser->get_userid();
+ $limits = $this->muser->get_upload_id_limits();
+ $multipaste_id = \service\files::create_multipaste($ids, $userid, $limits)["url_id"];
+
+ $ids[] = $multipaste_id;
+ $this->_prepare_claim($ids, false);
+
+ redirect(site_url($multipaste_id)."/");
+ }
+
+ $this->_show_url($ids, false);
+ }
+
+ private function _handle_files($files)
+ {
+ $ids = array();
+
+ if (!empty($files)) {
+ $limits = $this->muser->get_upload_id_limits();
+ $userid = $this->muser->get_userid();
+ service\files::verify_uploaded_files($files);
+
+ foreach ($files as $key => $file) {
+ $id = $this->mfile->new_id($limits[0], $limits[1]);
+ service\files::add_uploaded_file($userid, $id, $file["tmp_name"], $file["name"]);
+ $ids[] = $id;
+ }
+ }
+
+ return $ids;
+ }
+
+ private function _handle_textarea($contents, $filenames)
+ {
+ $ids = array();
+
+ foreach ($contents as $key => $content) {
+ $filesize = strlen($content);
+
+ if ($filesize == 0) {
+ unset($contents[$key]);
+ }
+
+ if ($filesize > $this->config->item('upload_max_size')) {
+ throw new \exceptions\RequestTooBigException("file/websubmit/request-too-big", "Error while uploading: Paste too big");
+ }
+ }
+
+ $limits = $this->muser->get_upload_id_limits();
+ $userid = $this->muser->get_userid();
+
+ foreach ($contents as $key => $content) {
+ $filename = "stdin";
+ if (isset($filenames[$key]) && $filenames[$key] != "") {
+ $filename = $filenames[$key];
+ }
+
+ $id = $this->mfile->new_id($limits[0], $limits[1]);
+ service\files::add_file_data($userid, $id, $content, $filename);
+ $ids[] = $id;
+ }
+
+ return $ids;
+ }
+
+ function claim_id()
+ {
+ $this->muser->require_access();
+
+ $last_upload = $this->session->userdata("last_upload");
+
+ if ($last_upload === false) {
+ throw new \exceptions\PublicApiException("file/claim_id/last_upload-failed", "Failed to get last upload data, unable to claim uploads");
+ }
+
+ $ids = $last_upload["ids"];
+ $errors = array();
+
+ assert(is_array($ids));
+
+ foreach ($ids as $key => $id) {
+ $affected = 0;
+ $affected += $this->mfile->adopt($id);
+ $affected += $this->mmultipaste->adopt($id);
+
+ if ($affected == 0) {
+ $errors[] = $id;
+ }
+ }
+
+ if (!empty($errors)) {
+ throw new \exceptions\PublicApiException("file/claim_id/failed", "Failed to claim ".implode(", ", $errors)."");
+ }
+
+ $this->session->unset_userdata("last_upload");
+
+ $this->_show_url($ids, $last_upload["lexer"]);
+ }
+
+ function contact()
+ {
+ $file = FCPATH."data/local/contact-info.php";
+ if (file_exists($file)) {
+ $this->data["contact_info"] = file_get_contents($file);
+ } else {
+ $this->data["contact_info"] = '<p>Contact info not available.</p>';
+ }
+
+ $this->load->view('header', $this->data);
+ $this->load->view('contact', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ /* Functions below this comment can only be run via the CLI
+ * `php index.php file <function name>`
+ */
+
+ // Removes old files
+ function cron()
+ {
+ $this->_require_cli_request();
+
+ $tarball_dir = $this->config->item("upload_path")."/special/multipaste-tarballs";
+ if (is_dir($tarball_dir)) {
+ $tarball_cache_time = $this->config->item("tarball_cache_time");
+ $it = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($tarball_dir), RecursiveIteratorIterator::SELF_FIRST);
+
+ foreach ($it as $file) {
+ if ($file->isFile()) {
+ if ($file->getMTime() < time() - $tarball_cache_time) {
+ $lock = fopen($file, "r+");
+ flock($lock, LOCK_EX);
+ unlink($file);
+ flock($lock, LOCK_UN);
+ }
+ }
+ }
+ }
+
+ $oldest_time = (time() - $this->config->item('upload_max_age'));
+ $oldest_session_time = (time() - $this->config->item("sess_expiration"));
+ $config = array(
+ "upload_max_age" => $this->config->item("upload_max_age"),
+ "small_upload_size" => $this->config->item("small_upload_size"),
+ "sess_expiration" => $this->config->item("sess_expiration"),
+ );
+
+ $query = $this->db->select('file_storage_id storage_id, files.id, user, files.date, hash')
+ ->from('files')
+ ->join('file_storage', "file_storage.id = files.file_storage_id")
+ ->where("user", 0)
+ ->where("files.date <", $oldest_session_time)
+ ->get()->result_array();
+
+ foreach($query as $row) {
+ $row['data_id'] = $row['hash'].'-'.$row['storage_id'];
+ \service\files::valid_id($row, $config, $this->mfile, time());
+ }
+
+ // 0 age disables age checks
+ if ($this->config->item('upload_max_age') == 0) return;
+
+ $query = $this->db->select('hash, files.id, user, files.date, file_storage.id storage_id')
+ ->from('files')
+ ->join('file_storage', "file_storage.id = files.file_storage_id")
+ ->where('files.date <', $oldest_time)
+ ->get()->result_array();
+
+ foreach($query as $row) {
+ $row['data_id'] = $row['hash'].'-'.$row['storage_id'];
+ \service\files::valid_id($row, $config, $this->mfile, time());
+ }
+ }
+
+ /* remove files without database entries */
+ function clean_stale_files()
+ {
+ $this->_require_cli_request();
+
+ $upload_path = $this->config->item("upload_path");
+ $outer_dh = opendir($upload_path);
+
+ while (($dir = readdir($outer_dh)) !== false) {
+ if (!is_dir($upload_path."/".$dir) || $dir == ".." || $dir == "." || $dir == "special") {
+ continue;
+ }
+
+ $dh = opendir($upload_path."/".$dir);
+
+ $empty = true;
+
+ while (($file = readdir($dh)) !== false) {
+ if ($file == ".." || $file == ".") {
+ continue;
+ }
+
+ try {
+ list($hash, $storage_id) = explode("-", $file);
+ } catch (\ErrorException $e) {
+ unlink($upload_path."/".$dir."/".$file);
+ continue;
+ }
+
+ $query = $this->db->select('hash, id')
+ ->from('file_storage')
+ ->where('hash', $hash)
+ ->where('id', $storage_id)
+ ->limit(1)
+ ->get()->row_array();
+
+ if (empty($query)) {
+ $this->mfile->delete_data_id($file);
+ } else {
+ $empty = false;
+ }
+ }
+
+ closedir($dh);
+
+ if ($empty && file_exists($upload_path."/".$dir)) {
+ rmdir($upload_path."/".$dir);
+ }
+ }
+ closedir($outer_dh);
+
+ // TODO: clean up special/multipaste-tarballs? cron() already expires
+ // after a rather short time, do we really need this here then?
+ }
+
+ function nuke_id()
+ {
+ $this->_require_cli_request();
+
+ $id = $this->uri->segment(3);
+
+ $file_data = $this->mfile->get_filedata($id);
+
+ if (empty($file_data)) {
+ echo "unknown id \"$id\"\n";
+ return;
+ }
+
+ $data_id = $file_data["data_id"];
+ $this->mfile->delete_data_id($data_id);
+ echo "removed data_id \"$data_id\"\n";
+ }
+
+ function update_file_metadata()
+ {
+ $this->_require_cli_request();
+
+ $chunk = 500;
+
+ $total = $this->db->count_all("file_storage");
+
+ for ($limit = 0; $limit < $total; $limit += $chunk) {
+ $query = $this->db->select('hash, id')
+ ->from('file_storage')
+ ->limit($chunk, $limit)
+ ->get()->result_array();
+
+ foreach ($query as $key => $item) {
+ $data_id = $item["hash"].'-'.$item['id'];
+ $filepath = $this->mfile->file($data_id);
+ $mimetype = mimetype($filepath);
+ $filesize = filesize($filepath);
+
+ $this->db->where('id', $item['id'])
+ ->set(array(
+ 'mimetype' => $mimetype,
+ 'filesize' => $filesize,
+ ))
+ ->update('file_storage');
+ }
+ }
+ }
+}
+
+# vim: set noet:
diff --git a/application/controllers/file/multipaste.php b/application/controllers/file/multipaste.php
new file mode 100644
index 000000000..50367697c
--- /dev/null
+++ b/application/controllers/file/multipaste.php
@@ -0,0 +1,113 @@
+<?php
+/*
+ * Copyright 2016 Florian "Bluewind" Pritz <bluewind@server-speed.net>
+ *
+ * Licensed under AGPLv3
+ * (see COPYING for full license text)
+ *
+ */
+
+class Multipaste extends MY_Controller {
+
+ function __construct() {
+ parent::__construct();
+
+ $this->load->model('mfile');
+ $this->load->model('mmultipaste');
+ }
+
+ public function append_multipaste_queue() {
+ $this->muser->require_access("basic");
+
+ $ids = $this->input->post_array("ids");
+ if ($ids === false) {
+ $ids = [];
+ }
+
+ $m = new \service\multipaste_queue();
+ $m->append($ids);
+
+ redirect("file/multipaste/queue");
+ }
+
+ public function review_multipaste() {
+ $this->muser->require_access("basic");
+
+ $this->load->view('header', $this->data);
+ $this->load->view('file/review_multipaste', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ public function queue() {
+ $this->muser->require_access("basic");
+
+ $m = new \service\multipaste_queue();
+ $ids = $m->get();
+
+ $this->data['ids'] = $ids;
+ $this->data['items'] = array_map(function($id) {return $this->_get_multipaste_item($id);}, $ids);
+
+ $this->load->view('header', $this->data);
+ $this->load->view('file/multipaste/queue', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ public function form_submit() {
+ $this->muser->require_access("basic");
+
+ $ids = $this->input->post_array('ids');
+ $process = $this->input->post('process');
+
+ if ($ids === false) {
+ $ids = [];
+ }
+
+ $m = new \service\multipaste_queue();
+ $m->set($ids);
+
+ $dispatcher = [
+ 'save' => function() use ($ids, $m) {
+ redirect("file/multipaste/queue");
+ },
+ 'create' => function() use ($ids, $m) {
+ $userid = $this->muser->get_userid();
+ $limits = $this->muser->get_upload_id_limits();
+ $ret = \service\files::create_multipaste($ids, $userid, $limits);
+ $m->set([]);
+ redirect($ret['url_id'].'/');
+ },
+ ];
+
+ if (isset($dispatcher[$process])) {
+ $dispatcher[$process]();
+ } else {
+ throw new \exceptions\UserInputException("file/multipaste/form_submit/invalid-process-value", "Value in process field not found in dispatch table");
+ }
+ }
+
+ public function ajax_submit() {
+ $this->muser->require_access("basic");
+ $ids = $this->input->post_array('ids');
+
+ if ($ids === false) {
+ $ids = [];
+ }
+
+ $m = new \service\multipaste_queue();
+ $m->set($ids);
+ }
+
+ private function _get_multipaste_item($id) {
+ $filedata = $this->mfile->get_filedata($id);
+ $item = [];
+ $item['id'] = $filedata['id'];
+ $item['tooltip'] = \service\files::tooltip($filedata);
+ $item['title'] = $filedata['filename'];
+ if (\libraries\Image::type_supported($filedata["mimetype"])) {
+ $item['thumbnail'] = site_url("file/thumbnail/".$filedata['id']);
+ }
+
+ return $item;
+ }
+
+}
diff --git a/application/controllers/tools.php b/application/controllers/tools.php
new file mode 100644
index 000000000..9e0ddfb5f
--- /dev/null
+++ b/application/controllers/tools.php
@@ -0,0 +1,130 @@
+<?php
+/*
+ * Copyright 2014 Florian "Bluewind" Pritz <bluewind@server-speed.net>
+ *
+ * Licensed under AGPLv3
+ * (see COPYING for full license text)
+ *
+ */
+
+class Tools extends MY_Controller {
+
+ function __construct()
+ {
+ parent::__construct();
+
+ $this->load->model('mfile');
+ $this->_require_cli_request();
+ }
+
+ function index()
+ {
+ echo "php index.php <controller> <function> [arguments]\n";
+ echo "\n";
+ echo "Functions:\n";
+ echo " file cron Cronjob\n";
+ echo " file nuke_id <ID> Nukes all IDs sharing the same hash\n";
+ echo " user cron Cronjob\n";
+ echo " tools update_database Update/Initialise the database\n";
+ echo "\n";
+ echo "Functions that shouldn't have to be run:\n";
+ echo " file clean_stale_files Remove files without database entries\n";
+ echo " file update_file_metadata Update filesize and mimetype in database\n";
+ exit;
+ }
+
+ function update_database()
+ {
+ $this->load->library('migration');
+ $upgraded = $this->migration->current();
+ if ( ! $upgraded) {
+ throw new \exceptions\ApiException("tools/update_database/migration-error", $this->migration->error_string());
+ }
+
+ if ($upgraded === true) {
+ echo "Already at latest database version. No upgrade performed\n";
+ }
+
+ if (is_int($upgraded)) {
+ echo "Database upgraded sucessfully to version: $upgraded\n";
+ }
+ }
+
+ function drop_all_tables()
+ {
+ $tables = $this->db->list_tables();
+ $prefix = $this->db->dbprefix;
+ $tables_to_drop = array();
+
+ foreach ($tables as $table) {
+ if ($prefix === "" || strpos($table, $prefix) === 0) {
+ $tables_to_drop[] = $this->db->protect_identifiers($table);
+ }
+ }
+
+ if (empty($tables_to_drop)) {
+ return;
+ }
+
+
+ if ($this->db->dbdriver !== 'postgre') {
+ $this->db->query('SET FOREIGN_KEY_CHECKS = 0');
+ }
+ $this->db->query('DROP TABLE '.implode(", ", $tables_to_drop));
+ if ($this->db->dbdriver !== 'postgre') {
+ $this->db->query('SET FOREIGN_KEY_CHECKS = 1');
+ }
+ }
+
+ function test()
+ {
+ global $argv;
+ $testcase = $argv[3];
+
+ $testcase = str_replace("application/", "", $testcase);
+ $testcase = str_replace("/", "\\", $testcase);
+ $testcase = str_replace(".php", "", $testcase);
+
+ $test = new $testcase();
+
+ $exitcode = 0;
+
+ $refl = new ReflectionClass($test);
+ foreach ($refl->getMethods() as $method) {
+ if (strpos($method->name, "test_") === 0) {
+ try {
+ $test->setTestNamePrefix($method->name." - ");
+ $test->init();
+ $test->setTestID("{$testcase}->{$method->name}");
+ $test->{$method->name}();
+ $test->cleanup();
+ } catch (\Exception $e) {
+ echo "not ok - uncaught exception in {$testcase}->{$method->name}\n";
+ \libraries\ExceptionHandler::exception_handler($e);
+ $exitcode = 255;
+ }
+ }
+ }
+
+ if ($exitcode == 0) {
+ $test->done_testing();
+ } else {
+ exit($exitcode);
+ }
+ }
+
+ function generate_coverage_report()
+ {
+ include APPPATH."../vendor/autoload.php";
+ $coverage = new \SebastianBergmann\CodeCoverage\CodeCoverage();
+ foreach (glob(FCPATH."/test-coverage-data/*") as $file) {
+ $coverage->merge(unserialize(file_get_contents($file)));
+ }
+
+ $writer = new \SebastianBergmann\CodeCoverage\Report\Clover();
+ $writer->process($coverage, 'code-coverage-report.xml');
+ $writer = new \SebastianBergmann\CodeCoverage\Report\Html\Facade();
+ $writer->process($coverage, 'code-coverage-report');
+ print "Report saved to ./code-coverage-report/index.html\n";
+ }
+}
diff --git a/application/controllers/user.php b/application/controllers/user.php
new file mode 100644
index 000000000..d87b544c7
--- /dev/null
+++ b/application/controllers/user.php
@@ -0,0 +1,705 @@
+<?php
+/*
+ * Copyright 2012-2013 Florian "Bluewind" Pritz <bluewind@server-speed.net>
+ *
+ * Licensed under AGPLv3
+ * (see COPYING for full license text)
+ *
+ */
+
+class User extends MY_Controller {
+
+ function __construct()
+ {
+ parent::__construct();
+ }
+
+ function index()
+ {
+ if ($this->input->is_cli_request()) {
+ $this->load->library("../controllers/tools");
+ return $this->tools->index();
+ }
+
+ $this->data["username"] = $this->muser->get_username();
+
+ $this->load->view('header', $this->data);
+ $this->load->view('user/index', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ function test_login()
+ {
+ $username = $this->input->post('username');
+ $password = $this->input->post('password');
+
+ if ($this->muser->login($username, $password)) {
+ $this->output->set_status_header(204);
+ } else {
+ $this->output->set_status_header(403);
+ }
+ }
+
+ function login()
+ {
+ $redirect_uri = $this->input->get("redirect_uri");
+ $this->muser->require_session();
+
+ if (!preg_match('/^[0-9a-zA-Z\/_-]*$/', $redirect_uri)) {
+ $redirect_uri = '/';
+ }
+
+ if ($this->muser->logged_in()) {
+ redirect($redirect_uri);
+ }
+
+ $this->data['redirect_uri'] = $redirect_uri;
+
+ if ($this->input->post('process') !== false) {
+ $username = $this->input->post('username');
+ $password = $this->input->post('password');
+
+ $result = $this->muser->login($username, $password);
+
+ if ($result !== true) {
+ $this->data['login_error'] = true;
+ $this->load->view('header', $this->data);
+ $this->load->view('user/login', $this->data);
+ $this->load->view('footer', $this->data);
+ } else {
+ redirect($redirect_uri);
+ }
+ } else {
+ $this->load->view('header', $this->data);
+ $this->load->view('user/login', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+ }
+
+ function create_apikey()
+ {
+ $this->muser->require_access();
+
+ $userid = $this->muser->get_userid();
+ $comment = $this->input->post("comment");
+ $comment = $comment === false ? "" : $comment;
+ $access_level = $this->input->post("access_level");
+
+ if ($access_level === false) {
+ $access_level = "apikey";
+ }
+
+ $key = \service\user::create_apikey($userid, $comment, $access_level);
+
+ redirect("user/apikeys");
+ }
+
+ function delete_apikey()
+ {
+ $this->muser->require_access();
+
+ $userid = $this->muser->get_userid();
+ $key = $this->input->post("key");
+
+ $this->db->where('user', $userid)
+ ->where('key', $key)
+ ->delete('apikeys');
+
+ redirect("user/apikeys");
+ }
+
+ function apikeys()
+ {
+ $this->muser->require_access();
+
+ $userid = $this->muser->get_userid();
+ $apikeys = \service\user::apikeys($userid);
+ $this->data["query"] = $apikeys["apikeys"];
+
+ $this->load->view('header', $this->data);
+ $this->load->view('user/apikeys', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ function create_invitation_key()
+ {
+ $this->duser->require_implemented("can_register_new_users");
+ $this->muser->require_access();
+
+ $userid = $this->muser->get_userid();
+
+ $invitations = $this->db->select('user')
+ ->from('actions')
+ ->where('user', $userid)
+ ->where('action', 'invitation')
+ ->count_all_results();
+
+ if ($invitations + 1 > $this->config->item('max_invitation_keys')) {
+ throw new \exceptions\PublicApiException("user/invitation-limit", "You can't create more invitation keys at this time.");
+ }
+
+ $key = random_alphanum(12, 16);
+
+ $this->db->set(array(
+ 'key' => $key,
+ 'user' => $userid,
+ 'date' => time(),
+ 'action' => 'invitation'
+ ))
+ ->insert('actions');
+
+ redirect("user/invite");
+ }
+
+ function invite()
+ {
+ $this->duser->require_implemented("can_register_new_users");
+ $this->muser->require_access();
+
+ $userid = $this->muser->get_userid();
+
+ $query = $this->db->select('key, date')
+ ->from('actions')
+ ->where('user', $userid)
+ ->where('action', 'invitation')
+ ->get()->result_array();
+
+ $this->data["query"] = $query;
+
+ $this->load->view('header', $this->data);
+ $this->load->view('user/invite', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ function register()
+ {
+ $this->duser->require_implemented("can_register_new_users");
+ $key = $this->uri->segment(3);
+ $process = $this->input->post("process");
+ $values = array(
+ "username" => "",
+ "email" => ""
+ );
+ $error = array();
+
+ $query = $this->muser->get_action("invitation", $key);
+
+ $referrer = $query["user"];
+
+ $this->data['redirect_uri'] = "/";
+
+ if ($process !== false) {
+ $username = $this->input->post("username");
+ $email = $this->input->post("email");
+ $password = $this->input->post("password");
+ $password_confirm = $this->input->post("password_confirm");
+
+ if (!$this->muser->valid_username($username)) {
+ $error[]= "Invalid username (only up to 32 chars of a-z0-9 are allowed).";
+ } else {
+ if ($this->muser->username_exists($username)) {
+ $error[] = "Username already exists.";
+ }
+ }
+
+ if (!$this->muser->valid_email($email)) {
+ $error[]= "Invalid email.";
+ }
+
+ if (!$password || $password !== $password_confirm) {
+ $error[]= "No password or passwords don't match.";
+ }
+
+ if (empty($error)) {
+ $this->muser->add_user($username, $password, $email, $referrer);
+
+ $this->db->where('key', $key)
+ ->delete('actions');
+
+ $this->load->view('header', $this->data);
+ $this->load->view('user/registered', $this->data);
+ $this->load->view('footer', $this->data);
+ return;
+ } else {
+ $values["username"] = $username;
+ $values["email"] = $email;
+ }
+ }
+
+ $this->data["key"] = $key;
+ $this->data["values"] = $values;
+ $this->data["error"] = $error;
+
+ $this->load->view('header', $this->data);
+ $this->load->view('user/register', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ public function delete_account()
+ {
+ $this->muser->require_access();
+ $this->duser->require_implemented("can_delete_account");
+
+ if ($_SERVER["REQUEST_METHOD"] == "GET") {
+ return $this->_delete_account_form();
+ } elseif ($_SERVER["REQUEST_METHOD"] == "POST") {
+ return $this->_delete_account_process();
+ }
+ }
+
+ public function _delete_account_form()
+ {
+ $this->data['username'] = $this->muser->get_username();
+
+ $this->load->view('header', $this->data);
+ $this->load->view('user/delete_account_form', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ public function _delete_account_process()
+ {
+ $username = $this->muser->get_username();
+ $password = $this->input->post("password");
+
+ $useremail = $this->muser->get_email($this->muser->get_userid());
+
+ if ($this->muser->delete_user($username, $password)) {
+ $this->muser->logout();
+
+ $this->load->library("email");
+ $this->email->from($this->config->item("email_from"));
+ $this->email->to($useremail);
+ $this->email->subject("FileBin account deleted");
+ $this->email->message(""
+ ."Your FileBin account '${username}' at ".site_url()."\n"
+ ."has been permemently deleted.\n"
+ ."\n"
+ ."The request has been sent from the IP address '${_SERVER["REMOTE_ADDR"]}'\n"
+ ."and was confirmed with your password.\n"
+ ."\n"
+ ."Thank you for using FileBin!\n"
+ );
+ $this->email->send();
+ unset($this->data['username']);
+ unset($this->data['user_logged_in']);
+
+ $this->load->view('header', $this->data);
+ $this->load->view('user/delete_account_success', $this->data);
+ $this->load->view('footer', $this->data);
+ return;
+ } else {
+ $this->data['alerts'][] = array(
+ "type" => "danger",
+ "message" => "Your password was incorrect",
+ );
+ return $this->_delete_account_form();
+ }
+ }
+
+ // This routes the different steps of a password reset
+ function reset_password()
+ {
+ $this->duser->require_implemented("can_reset_password");
+ $key = $this->uri->segment(3);
+
+ if ($_SERVER["REQUEST_METHOD"] == "GET" && $key === false) {
+ return $this->_reset_password_username_form();
+ }
+
+ if ($key === false) {
+ return $this->_reset_password_send_mail();
+ }
+
+ if ($key !== false) {
+ return $this->_reset_password_form();
+ }
+ }
+
+ // This simply queries the username
+ function _reset_password_username_form()
+ {
+ $this->data['username'] = $this->muser->get_username();
+
+ $this->load->view('header', $this->data);
+ $this->load->view('user/reset_password_username_form', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ // This sends a mail to the user containing the reset link
+ function _reset_password_send_mail()
+ {
+ $key = random_alphanum(12, 16);
+ $username = $this->input->post("username");
+
+ if (!$this->muser->username_exists($username)) {
+ throw new \exceptions\PublicApiException("user/reset_password/invalid-username", "Invalid username");
+ }
+
+ $userinfo = $this->db->select('id, email, username')
+ ->from('users')
+ ->where('username', $username)
+ ->get()->row_array();
+
+ $this->load->library("email");
+
+ $this->db->set(array(
+ 'key' => $key,
+ 'user' => $userinfo['id'],
+ 'date' => time(),
+ 'action' => 'passwordreset'
+ ))
+ ->insert('actions');
+
+ $this->email->from($this->config->item("email_from"));
+ $this->email->to($userinfo["email"]);
+ $this->email->subject("FileBin password reset");
+ $this->email->message(""
+ ."Someone requested a password reset for the account '${userinfo["username"]}'\n"
+ ."from the IP address '${_SERVER["REMOTE_ADDR"]}'.\n"
+ ."\n"
+ ."Please follow this link to reset your password:\n"
+ .site_url("user/reset_password/$key")
+ );
+ $this->email->send();
+
+ // don't disclose full email addresses
+ $this->data["email_domain"] = substr($userinfo["email"], strpos($userinfo["email"], "@") + 1);
+
+ $this->load->view('header', $this->data);
+ $this->load->view('user/reset_password_link_sent', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ // This displays a form and handles the reset if the form has been filled out correctly
+ function _reset_password_form()
+ {
+ $process = $this->input->post("process");
+ $key = $this->uri->segment(3);
+ $error = array();
+
+ $query = $this->muser->get_action("passwordreset", $key);
+
+ $userid = $query["user"];
+
+ if ($process !== false) {
+ $password = $this->input->post("password");
+ $password_confirm = $this->input->post("password_confirm");
+
+ if (!$password || $password !== $password_confirm) {
+ $error[]= "No password or passwords don't match.";
+ }
+
+ if (empty($error)) {
+ $this->muser->set_password($userid, $password);
+
+ $this->db->where('key', $key)
+ ->delete('actions');
+
+ $this->load->view('header', $this->data);
+ $this->load->view('user/reset_password_success', $this->data);
+ $this->load->view('footer', $this->data);
+ return;
+ }
+ }
+
+ $this->data["key"] = $key;
+ $this->data["error"] = $error;
+
+ $this->load->view('header', $this->data);
+ $this->load->view('user/reset_password_form', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ public function change_email()
+ {
+ $this->duser->require_implemented("can_change_email");
+ $key = $this->uri->segment(3);
+ $action = $this->uri->segment(4);
+
+ $alerts = array();
+
+ $query = $this->muser->get_action("change_email", $key);
+
+ $userid = $query["user"];
+ $data = json_decode($query['data'], true);
+
+ switch ($action) {
+ case 'confirm':
+ $this->db->where('id', $userid)
+ ->update('users', array(
+ "email" => $data['new_email'],
+ ));
+ $alerts[] = array(
+ "type" => "success",
+ "message" => "Your email address has been updated",
+ );
+ break;
+ case 'reject':
+ $this->db->where('id', $userid)
+ ->update('users', array(
+ "email" => $data['old_email'],
+ ));
+ foreach ($data['keys'] as $k) {
+ $this->db->where('key', $k)
+ ->delete('actions');
+ }
+ $alerts[] = array(
+ "type" => "success",
+ "message" => "Your email change request has been canceled and/or your old email address has been restored",
+ );
+ break;
+ default:
+ assert(false);
+ break;
+ }
+
+ $this->data["alerts"] = $alerts;
+
+ return $this->profile();
+ }
+
+ function profile()
+ {
+ $this->muser->require_access();
+
+ if ($this->input->post("process") !== false) {
+ $this->_save_profile();
+ }
+
+ $this->data["profile_data"] = $this->muser->get_profile_data();
+
+ $this->load->view('header', $this->data);
+ $this->load->view('user/profile', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ private function _save_profile()
+ {
+ $this->muser->require_access();
+
+ $old = $this->muser->get_profile_data();
+
+ /*
+ * Key = name of the form field
+ * Value = function that sanatizes the value and returns it
+ * TODO: some kind of error handling that doesn't loose correctly filled out fields
+ */
+ $value_processor = array();
+ $alerts = array();
+
+ $value_processor["upload_id_limits"] = function($value) {
+ $values = explode("-", $value);
+
+ if (!is_array($values) || count($values) != 2) {
+ throw new \exceptions\PublicApiException("user/profile/invalid-upload-id-limit", "Invalid upload id limit value");
+ }
+
+ $lower = intval($values[0]);
+ $upper = intval($values[1]);
+
+ if ($lower > $upper) {
+ throw new \exceptions\PublicApiException("user/profile/lower-bigger-than-upper", "lower limit > upper limit");
+ }
+
+ if ($lower < 3 || $upper > 64) {
+ throw new \exceptions\PublicApiException("user/profile/limit-out-of-bounds", "upper or lower limit out of bounds (3-64)");
+ }
+
+ return $lower."-".$upper;
+ };
+
+ $value_processor["email"] = function($value) use ($old, &$alerts) {
+ if (!$this->duser->is_implemented("can_change_email")) {
+ return null;
+ }
+
+ if ($value === $old["email"]) {
+ return null;
+ }
+
+ if (!$this->muser->valid_email($value)) {
+ throw new \exceptions\PublicApiException("user/profile/invalid-email", "Invalid email");
+ }
+
+ $this->load->library("email");
+ $keys = array(
+ "old" => random_alphanum(12,16),
+ "new" => random_alphanum(12,16),
+ );
+ $emails = array(
+ array(
+ "key" => $keys['old'],
+ "email" => $old['email'],
+ "user" => $this->muser->get_userid(),
+ ),
+ array(
+ "key" => $keys['new'],
+ "email" => $value,
+ "user" => $this->muser->get_userid(),
+ ),
+ );
+
+ foreach ($emails as $email) {
+ $key = $email['key'];
+
+ $this->db->set(array(
+ 'key' => $key,
+ 'user' => $this->muser->get_userid(),
+ 'date' => time(),
+ 'action' => 'change_email',
+ 'data' => json_encode(array(
+ 'old_email' => $old['email'],
+ 'new_email' => $value,
+ 'keys' => $keys,
+ )),
+ ))
+ ->insert('actions');
+
+ $this->email->from($this->config->item("email_from"));
+ $this->email->to($email['email']);
+ $this->email->subject("FileBin email change confirmation");
+ $this->email->message(""
+ ."A request has been sent to change the email address of account '${old["username"]}'\n"
+ ."from ".$old['email']." to $value.\n"
+ ."\n"
+ ."Please follow this link to CONFIRM the change:\n"
+ .site_url("user/change_email/$key/confirm")."\n\n"
+ ."Please follow this link to REJECT the change:\n"
+ .site_url("user/change_email/$key/reject")."\n\n"
+ );
+ $this->email->send();
+ $this->email->clear();
+ }
+
+ $alerts[] = array(
+ "type" => "info",
+ "message" => "Reset and confirmation emails have been sent to your new and old address. Until your new address is confirmed the old one will be displayed and used.",
+ );
+
+ return null;
+ };
+
+
+ $data = array();
+ foreach (array_keys($value_processor) as $field) {
+ $value = $this->input->post($field);
+
+ if ($value !== false) {
+ $new_value = $value_processor[$field]($value);
+ if ($new_value !== null) {
+ $data[$field] = $new_value;
+ }
+ }
+ }
+
+ if (!empty($data)) {
+ $this->muser->update_profile($data);
+ }
+
+ $alerts[] = array(
+ "type" => "success",
+ "message" => "Changes saved",
+ );
+ $this->data["alerts"] = $alerts;
+
+ return true;
+ }
+
+ function logout()
+ {
+ $this->muser->logout();
+ redirect('/');
+ }
+
+ function hash_password()
+ {
+ $process = $this->input->post("process");
+ $password = $this->input->post("password");
+ $password_confirm = $this->input->post("password_confirm");
+ $this->data["hash"] = false;
+ $this->data["password"] = $password;
+
+ if ($process !== false) {
+ if (!$password || $password !== $password_confirm) {
+ $error[]= "No password or passwords don't match.";
+ } else {
+ $this->data["hash"] = $this->muser->hash_password($password);
+ }
+ }
+
+ $this->load->view('header', $this->data);
+ $this->load->view('user/hash_password', $this->data);
+ $this->load->view('footer', $this->data);
+ }
+
+ function cron()
+ {
+ $this->_require_cli_request();
+
+ if ($this->config->item('actions_max_age') == 0) return;
+
+ $oldest_time = (time() - $this->config->item('actions_max_age'));
+
+ $this->db->where('date <', $oldest_time)
+ ->delete('actions');
+ }
+
+ private function _get_line_cli($message, $verification_func = NULL)
+ {
+ echo "$message: ";
+
+ while ($line = fgets(STDIN)) {
+ $line = trim($line);
+ if ($verification_func === NULL) {
+ return $line;
+ }
+
+ if ($verification_func($line)) {
+ return $line;
+ } else {
+ echo "$message: ";
+ }
+ }
+ }
+
+ function add_user()
+ {
+ $this->_require_cli_request();
+ $this->duser->require_implemented("can_register_new_users");
+
+ $error = array();
+
+ $username = $this->_get_line_cli("Username", function($username) {
+ if (!$this->muser->valid_username($username)) {
+ echo "Invalid username (only up to 32 chars of a-z0-9 are allowed).\n";
+ return false;
+ } else {
+ if (get_instance()->muser->username_exists($username)) {
+ echo "Username already exists.\n";
+ return false;
+ }
+ }
+ return true;
+ });
+
+ $email = $this->_get_line_cli("Email", function($email) {
+ if (!$this->muser->valid_email($email)) {
+ echo "Invalid email.\n";
+ return false;
+ }
+ return true;
+ });
+
+ $password = $this->_get_line_cli("Password", function($password) {
+ if (!$password || $password === "") {
+ echo "No password supplied.\n";
+ return false;
+ }
+ return true;
+ });
+
+ $this->muser->add_user($username, $password, $email, NULL);
+
+ echo "User added\n";
+ }
+}