
/* Copyright 2021 m-privacy GmbH.
 *
 * This is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this software; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307,
 * USA.
 */

#ifndef WIN32

#include <curl/curl.h>
#include <chrono>
#include <filesystem>
#include <iostream>
#include <nlohmann/json.hpp>
#include <pwd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>

#if !defined(WIN32) && !defined(WIN64)
#include <syslog.h>
#endif

#include <rfb/LogWriter.h>

#include <tgpro_environment.h>
#include <mp_utils.h>

#include "SAutotransferOpswat.h"

using namespace std;
using json = nlohmann::json;

static rfb::LogWriter vlog("SAutotransferOpswat");

rfb::StringParameter opswatIp
("opswatIp",
 "IP address of the OPSWAT server to clean files and (auto)-download them to the client",
 "");
rfb::StringParameter opswatPort
("opswatPort",
 "Port of the OPSWAT API server to clean files and (auto)-download them to the client",
 "8008");
rfb::IntParameter opswatTimeout("OpswatTimeout", "OPSWAP timeout in seconds (min 60, max 3600)", 300, 60, 3600);

#define CA_CERTS_FILE "/home/user/.customopswatca/ca-certificates"

SAutotransferOpswat::SAutotransferOpswat() {
	snprintf(userAgent, 255, "TightGate-Pro OPSWAT client/%s (Build %s)", PACKAGE_VERSION, BUILD_TIMESTAMP);
	userAgent[255] = '\0';
	vlog.debug("%s: Set User-Agent to '%s' (for libcurl calls)", __func__, userAgent);
	vlog.debug("%s: Setting timeout for processFile calls to %d seconds", __func__, (int) opswatTimeout);
	setProcessFileTimeout((int) opswatTimeout);
	setLogPathForEvents("/home/user/.opswatlog/" + getEffectiveUser()); // For mp-watch
}

SAutotransferOpswat::~SAutotransferOpswat() {
}

string SAutotransferOpswat::getBaseUrl()
{
	if (baseUrl.empty()) {
		string protocol = file_exists(CA_CERTS_FILE) ? "https" : "http";
		baseUrl = protocol + "://" + opswatIp.getData() + ":" + opswatPort.getData();
		vlog.verbose("%s: base url for OPSWAT curl API requests: %s", __func__, baseUrl.c_str());
	}
	return baseUrl;
}

string SAutotransferOpswat::getRule()
{
	//vlog.info("BEWARE! Hard-coded rule name: File process(mprivacy)");
	//return "File process(mprivacy)";

	if (rule.empty()) {
		string rulesFile = "/home/user/.opswatenabled/" + getEffectiveUser();
		string line;

		FILE* f = fopen(rulesFile.c_str(), "r");
		if (!f) {
			vlog.error("%s: failed to open rules file %s", __func__, rulesFile.c_str());
			return "";
		}

		int found = 0;
		char buf[4096];
		while (fgets(buf, sizeof(buf), f) != NULL) {
			if (buf[0] == '#' || buf[0] == '\n') {
				vlog.verbose("%s: Skipping line (starts with # or is empty)", __func__);
				continue;
			}
			line = buf;
			found = 1;
			break;
		}

		fclose(f);

		if (!found) {
			vlog.debug("%s: no valid OPSWAT rule found in %s", __func__, rulesFile.c_str());
			return "";
		}

		size_t newlinePos = line.find('\n');
		if (newlinePos != string::npos) {
			line.erase(newlinePos);
		}

		rule = line;
	}

	return rule;
}

std::string SAutotransferOpswat::getDirToWatch() const
{
	return "/home/user/.transfer/" + getEffectiveUser() + "/opswat";
}

void SAutotransferOpswat::processFile(char* filename)
{
	processFileImpl(filename);
}

void SAutotransferOpswat::processFileImpl(char* filename)
{
	vlog.debug("%s: OPSWATting file '%s'", __func__, filename);

	string uploadResult = uploadFile(filename);
	string user = getEffectiveUser();
	vlog.debug("%s: effective user: %s", __func__, user.c_str());

	json uploadJsonResult;
	try {
		uploadJsonResult = json::parse(uploadResult);
	} catch (const json::parse_error& e) {
		vlog.error("%s: failed to parse JSON response from server while uploading file %s (complete response <%s>). Error: %s",
			__func__, filename, uploadResult.c_str(), e.what());
		openlog("Xtightgatevnc", LOG_PID, LOG_AUTH);
		syslog(LOG_ERR, "OPSWAT error: failed to parse JSON response from server while uploading file %s (complete response <%s>)",
			filename, uploadResult.c_str());
		return;
	}

	if (!uploadJsonResult.contains("data_id")) {
		vlog.error("%s: JSON response does not contain 'data_id'. Here is the complete response: %s", __func__, uploadResult.c_str());
		syslog(LOG_ERR, "OPSWAT error: JSON response does not contain 'data_id'. Here is the complete response: %s", uploadResult.c_str());
		return;
	}

	string dataId = uploadJsonResult["data_id"];
	string baseUrl = getBaseUrl();
	if (baseUrl.empty()) {
		vlog.error("%s: getBaseUrl() returned empty", __func__);
		return;
	}

	auto startTime = std::chrono::steady_clock::now();
	auto nextLogTime = startTime + std::chrono::minutes(1);

	while (true) {
		string fileAnalyzeResult = getFileAnalyzeResultJson(dataId);
		json fileAnalyzeResultJson;
		try {
			fileAnalyzeResultJson = json::parse(fileAnalyzeResult);
		} catch (const json::parse_error& e) {
			vlog.error("%s: failed to parse JSON for '%s': %s", __func__, filename, e.what());
			break;
		}
		unsigned int progressPercentage = fileAnalyzeResultJson["process_info"]["progress_percentage"];

		// log every 60 seconds
		auto now = std::chrono::steady_clock::now();
		if (now >= nextLogTime) {
			auto elapsedSec = std::chrono::duration_cast<std::chrono::seconds>(now - startTime).count();
			vlog.debug("%s: '%s' progress percentage: %u (elapsed: %lds)", __func__, filename, progressPercentage, elapsedSec);
			nextLogTime += std::chrono::minutes(1);
		} else {
			vlog.verbose("%s: '%s' progress percentage: %i", __func__, filename, progressPercentage);
		}

		if (progressPercentage == 100) {
			string scanAllResult;
			string convertedDestination;

			vlog.verbose("%s: '%s' fileAnalyzeResult: %s", __func__, filename, fileAnalyzeResult.c_str());

			if (fileAnalyzeResultJson.contains("process_info") &&
				fileAnalyzeResultJson["process_info"].contains("post_processing") &&
				fileAnalyzeResultJson["process_info"]["post_processing"].contains("converted_destination"))
			{
				convertedDestination = fileAnalyzeResultJson["process_info"]["post_processing"]["converted_destination"].get<string>();
				if (!convertedDestination.empty() && starts_with(convertedDestination.c_str(), "(user: ")) {
					const char* position = strstr(convertedDestination.c_str(), ") ");
					if (position) convertedDestination = position + 2;
				}
			}

			if (fileAnalyzeResultJson.contains("scan_results") &&
				fileAnalyzeResultJson["scan_results"].contains("scan_all_result_a"))
			{
				scanAllResult = fileAnalyzeResultJson["scan_results"]["scan_all_result_a"].get<string>();
			} else {
				scanAllResult = "unknown reason (not specified in JSON)";
				vlog.error("%s: failed to read [\"scan_results\"][\"scan_all_result_a\"] for file '%s'",
					__func__, filename);
			}

			if (!fileAnalyzeResultJson.contains("process_info") ||
				!fileAnalyzeResultJson["process_info"].contains("result"))
			{
				vlog.error("%s: failed to read [\"process_info\"][\"result\"]. Can't continue processing file!", __func__);
				return;
			}

			if (fileAnalyzeResultJson["process_info"]["result"] != "Allowed") {
				delete_file(filename);
				vlog.info("%s: OPSWAT rejects %s, scan result: %s", __func__, filename, scanAllResult.c_str());
				openlog("Xtightgatevnc", LOG_PID, LOG_AUTH);
				syslog(LOG_DEBUG, "OPSWAT rejects %s, scan result: %s", filename, scanAllResult.c_str());

				// Use new logging function
				logEvent(filename, "REJECTED");
				return;
			} else {
				vlog.debug("%s: OPSWAT accepts %s, scan result: %s", __func__, filename, scanAllResult.c_str());
			}

			string outputFile;
			if (!convertedDestination.empty()) {
				outputFile = "/home/user/.autotransfer-now/" + user + "/" + convertedDestination;
			} else {
				string filenameCopy(filename);
				char* baseFilename = basename(&filenameCopy[0]);
				outputFile = "/home/user/.autotransfer-now/" + user + "/" + string(baseFilename);
			}

			if (fileAnalyzeResultJson.contains("sanitization_details") &&
				fileAnalyzeResultJson["sanitization_details"].value("description", "") == "Sanitized successfully")
			{
				if (getSanitizedFile(dataId, outputFile) == CURLE_OK) {
					vlog.debug("%s: '%s' sanitized. Downloaded to '%s'", __func__, filename, outputFile.c_str());
				}
			} else {
				mp_move(filename, outputFile.c_str(), 1);
				vlog.debug("%s: '%s' moved to '%s'", __func__, filename, outputFile.c_str());
			}

			delete_file(filename);

			if (strstr(fileAnalyzeResult.c_str(), "\"severity_index\""))
				vlog.debug("'%s' has severity_index", filename);

			break;
		}

		sleep(2);
	}
}

/**
 * Get the size of a file.
 * @param filename The name of the file to check size for
 * @return The filesize, or 0 if the file does not exist.
 */
static size_t getFilesize(const char* filename) {
	struct stat st;
	if(stat(filename, &st) != 0) {
		return 0;
	}
	return st.st_size;
}

static size_t writeCallback(void *contents, size_t size, size_t nmemb, void *userp)
{
	((string*) userp)->append((char*)contents, size * nmemb);
	return size * nmemb;
}

static size_t readCallback(char *ptr, size_t size, size_t nmemb, void *userdata)
{
	FILE *readhere = (FILE *)userdata;
	size_t retcode = fread(ptr, size, nmemb, readhere);
	return (curl_off_t)retcode;
}

string SAutotransferOpswat::getFileAnalyzeResultJson(const string dataId)
{
	string baseUrl = getBaseUrl();
	if (baseUrl.empty()) {
		vlog.error("%s: getBaseUrl() returned empty", __func__);
		return ""; // TODO something better than this. Maybe even create our own JSON?
	}
	string url = baseUrl + "/file/" + dataId;
	vlog.verbose("%s: getting file analysis result with '%s'", __func__, url.c_str());

	CURL* curl;
	CURLcode curlResult;
	string json;

	curl = curl_easy_init();
	if (curl) {
		if (file_exists(CA_CERTS_FILE)) {
			curl_easy_setopt(curl, CURLOPT_CAINFO, CA_CERTS_FILE);
		}
		curl_easy_setopt(curl, CURLOPT_USERAGENT, userAgent);
		curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
		curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback);
		curl_easy_setopt(curl, CURLOPT_WRITEDATA, &json);
		curlResult = curl_easy_perform(curl);
		curl_easy_cleanup(curl);

		if (curlResult == CURLE_OK) {
			return json;
		} else {
			string curlError = curl_easy_strerror(curlResult);
			vlog.error("%s: curl error (%d): %s", __func__, (int)curlResult, curlError.c_str());
			openlog("Xtightgatevnc", LOG_PID, LOG_AUTH);
			syslog(LOG_ERR, "OPSWAT error: curl error (%d): %s", (int)curlResult, curlError.c_str());
			// TODO something better than not doing anything and returning a "" at the end. Maybe even create our own JSON?
		}
	}

	return ""; // TODO something better than this. Maybe even create our own JSON?
}

CURLcode SAutotransferOpswat::getSanitizedFile(const string dataId, const string outputFile)
{
	string baseUrl = getBaseUrl();
	if (baseUrl.empty()) {
		vlog.error("%s: getBaseUrl() returned empty", __func__);
		return CURLE_FAILED_INIT;
	}

	string url = baseUrl + "/file/converted/" + dataId;
	vlog.debug("%s: trying to download sanitized file from: %s", __func__, url.c_str());

	CURL* curl;
	CURLcode curlResult = CURLE_OK;
	string json;
	FILE* fp;
	curl = curl_easy_init();
	if (curl) {
		if (file_exists(CA_CERTS_FILE)) {
			curl_easy_setopt(curl, CURLOPT_CAINFO, CA_CERTS_FILE);
		}
		fp = fopen(outputFile.c_str(), "wb");
		curl_easy_setopt(curl, CURLOPT_USERAGENT, userAgent);
		curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
		curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, NULL); // We don't need to write a callback function because it's the "standard" way
		curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
		curlResult = curl_easy_perform(curl);
		curl_easy_cleanup(curl);
		fclose(fp);
		if (curlResult != CURLE_OK) {
			string curlError = curl_easy_strerror(curlResult);
			vlog.error("%s: curl error (%d): %s", __func__, (int)curlResult, curlError.c_str());
			openlog("Xtightgatevnc", LOG_PID, LOG_AUTH);
			syslog(LOG_ERR, "OPSWAT error: curl error (%d): %s", (int)curlResult, curlError.c_str());
			// TODO something better than not doing anything and returning a "" at the end. Maybe even create our own JSON?
		}

	}

	return curlResult; // TODO something better than this. Maybe even create our own JSON?
}

string SAutotransferOpswat::uploadFile(const char* filename)
{
	string baseUrl = getBaseUrl();
	if (baseUrl.empty()) {
		vlog.error("%s: getBaseUrl() returned empty", __func__);
		return "";
	}
	string url = baseUrl + "/file";

	struct curl_slist* headers = NULL;

	string filenameCopy(filename); // so that basename() can't potentially break filename
	char* baseFilename = basename(&filenameCopy[0]); // non-const buffer for basename (c_str() is a const char*)
	string filenameHeader = "filename: (user: " + getEffectiveUser() + ") " + string(baseFilename);
	headers = curl_slist_append(headers, filenameHeader.c_str());	headers = curl_slist_append(headers, filenameHeader.c_str());
	vlog.verbose("%s: added HTTP header for curl POST: %s", __func__, filenameHeader.c_str());

	string rule = getRule();

	if (!rule.empty()) {
		vlog.debug("%s: using OPSWAT rule '%s'", __func__, rule.c_str());
		string ruleHeader = "rule: " + rule;
		headers = curl_slist_append(headers, ruleHeader.c_str());
		vlog.verbose("%s: added HTTP header for curl POST: %s", __func__, ruleHeader.c_str());
	} else {
		vlog.debug("%s: no OPSWAT rule set for user. Calling OPSWAT without specifying a rule (this isn't necessarily bad)", __func__);
	}

	CURL* curl;
	CURLcode curlResult;
	string json;

	vlog.debug("%s: Uploading file to OPSWAT using this URL: %s", __func__, url.c_str());

	curl = curl_easy_init();
	if (curl) {
		FILE* file = fopen(filename, "rb");
		if (!file) {
			vlog.error("%s: failed to open file '%s': %s", __func__, filename, strerror(errno));
			openlog("Xtightgatevnc", LOG_PID, LOG_AUTH);
			syslog(LOG_ERR, "OPSWAT error: failed to open file '%s': %s", filename, strerror(errno));
			return "";
		}

		if (file_exists(CA_CERTS_FILE)) {
			curl_easy_setopt(curl, CURLOPT_CAINFO, CA_CERTS_FILE);
		}
		curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
		curl_easy_setopt(curl, CURLOPT_USERAGENT, userAgent);
		curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
		curl_easy_setopt(curl, CURLOPT_POST, 1L);
		curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, getFilesize(filename));
		curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback);
		curl_easy_setopt(curl, CURLOPT_WRITEDATA, &json);
		curl_easy_setopt(curl, CURLOPT_READFUNCTION, readCallback);
		curl_easy_setopt(curl, CURLOPT_READDATA, (void *)file);
		curlResult = curl_easy_perform(curl);
		curl_easy_cleanup(curl);
		curl_slist_free_all(headers);
		fclose(file);

		if (curlResult == CURLE_OK) {
			//			delete_file(filename);
			return json;
		} else {
			string curlError = curl_easy_strerror(curlResult);
			vlog.error("%s: curl error (%d): %s", __func__, (int)curlResult, curlError.c_str());
			openlog("Xtightgatevnc", LOG_PID, LOG_AUTH);
			syslog(LOG_ERR, "OPSWAT error: curl error (%d): %s", (int)curlResult, curlError.c_str());
			// TODO something better than not doing anything and returning a "" at the end. Maybe even create our own JSON?
		}
	}

	return ""; // TODO something better than this. Maybe even create our own JSON?
}

#endif /* #ifndef WIN32 */
