mirror of
https://github.com/HackHerz/pusher
synced 2025-12-06 02:10:19 +00:00
First commit
This commit is contained in:
commit
b403801750
6 changed files with 571 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# executable
|
||||||
|
pusher
|
||||||
|
|
||||||
|
# makefile
|
||||||
|
makefile
|
||||||
13
LICENSE
Normal file
13
LICENSE
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
Copyright (c) 2014 HackHerz
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1) Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2) Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3) If you modify the source in any way, you have to remove the authors PushNotifier API-token and replace it with your own. It is the same for the package-name.
|
||||||
|
|
||||||
|
4) You have to make sure that the modified program doesn't break the PushNotifer API rules.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
20
README.md
Normal file
20
README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# pusher
|
||||||
|
Pusher is a program to notify you.
|
||||||
|
|
||||||
|
|
||||||
|
# Building
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
* boost
|
||||||
|
* curl
|
||||||
|
|
||||||
|
## Compile
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Documentation
|
||||||
242
src/main.cpp
Normal file
242
src/main.cpp
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
/*
|
||||||
|
* pusher
|
||||||
|
* (c) 2014 Daniel Stein
|
||||||
|
* github link..
|
||||||
|
*
|
||||||
|
* TODO
|
||||||
|
* syslog
|
||||||
|
* content type detection
|
||||||
|
* gitignore of lines
|
||||||
|
* secret input of password
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
// Boost
|
||||||
|
#include <boost/program_options.hpp>
|
||||||
|
#include <boost/format.hpp>
|
||||||
|
#include <boost/property_tree/ptree.hpp>
|
||||||
|
#include <boost/property_tree/ini_parser.hpp>
|
||||||
|
|
||||||
|
// own header files
|
||||||
|
#include "pushhandler.h"
|
||||||
|
|
||||||
|
|
||||||
|
#define CONFIG_FILE "/etc/pusher.conf"
|
||||||
|
|
||||||
|
// namespaces
|
||||||
|
using namespace std;
|
||||||
|
namespace po = boost::program_options;
|
||||||
|
|
||||||
|
|
||||||
|
// number of digit
|
||||||
|
int numDigits(int number)
|
||||||
|
{
|
||||||
|
int digits = 0;
|
||||||
|
while(number != 0)
|
||||||
|
{
|
||||||
|
number /= 10;
|
||||||
|
digits++;
|
||||||
|
}
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// main
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
|
||||||
|
po::options_description desc("Allowed options");
|
||||||
|
|
||||||
|
desc.add_options()
|
||||||
|
("help,h", "Print help message")
|
||||||
|
("token,t", "Request your token") // TODO
|
||||||
|
("list,l", "List of all your devices") // TODO
|
||||||
|
("pipe,p", "Input via pipe") // TODO
|
||||||
|
("quiet,q", "Outputs are redirected to syslog") // TODO
|
||||||
|
("verify,v", "Checks if token is still valid") // TODO
|
||||||
|
("id,i", po::value<int>(), "Specify device ID"); // TODO
|
||||||
|
|
||||||
|
po::variables_map vm;
|
||||||
|
po::store(po::parse_command_line(argc, argv, desc), vm);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string message;
|
||||||
|
int id;
|
||||||
|
|
||||||
|
|
||||||
|
// help
|
||||||
|
if(vm.count("help"))
|
||||||
|
{
|
||||||
|
cout << desc << endl;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// request token
|
||||||
|
if(vm.count("token"))
|
||||||
|
{
|
||||||
|
//request token
|
||||||
|
string username, password, token;
|
||||||
|
|
||||||
|
cout << "Username: ";
|
||||||
|
cin >> username;
|
||||||
|
|
||||||
|
cout << "Password: ";
|
||||||
|
cin >> password;
|
||||||
|
|
||||||
|
PushHandler buf(username);
|
||||||
|
token = buf.login(password);
|
||||||
|
|
||||||
|
// generate config file
|
||||||
|
stringstream conffile;
|
||||||
|
conffile << boost::format("[pusher]\nusername=%1%\nappToken=%2%")
|
||||||
|
% username
|
||||||
|
% token;
|
||||||
|
|
||||||
|
// attempt to save token
|
||||||
|
ofstream dat_aus;
|
||||||
|
dat_aus.open(CONFIG_FILE, ios_base::out);
|
||||||
|
|
||||||
|
if(!dat_aus.is_open())
|
||||||
|
{
|
||||||
|
cout << "Try running pusher as root or save the following in " << CONFIG_FILE << "\n" << endl;
|
||||||
|
cout << conffile.str() << endl;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// save
|
||||||
|
dat_aus << conffile.str();
|
||||||
|
dat_aus.close();
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// load conf and check if token is specified
|
||||||
|
boost::property_tree::ptree pt;
|
||||||
|
boost::property_tree::ini_parser::read_ini(CONFIG_FILE, pt);
|
||||||
|
string username = pt.get<std::string>("pusher.username");
|
||||||
|
string appToken = pt.get<std::string>("pusher.appToken");
|
||||||
|
|
||||||
|
|
||||||
|
// loading values
|
||||||
|
PushHandler pusherInstance(username, appToken);
|
||||||
|
|
||||||
|
|
||||||
|
// verify token
|
||||||
|
if(vm.count("verify"))
|
||||||
|
{
|
||||||
|
if(pusherInstance.verifyToken())
|
||||||
|
{
|
||||||
|
cout << "appToken is valid" << endl;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cout << "appToken is invalid" << endl;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// list devices
|
||||||
|
if(vm.count("list"))
|
||||||
|
{
|
||||||
|
vector<PushHandler::Device> devices;
|
||||||
|
devices = pusherInstance.getDevices();
|
||||||
|
|
||||||
|
int titleLength = 5;
|
||||||
|
int modelLength = 5;
|
||||||
|
int idLength = 2;
|
||||||
|
|
||||||
|
for(int i = 0; i < devices.size(); i++)
|
||||||
|
{
|
||||||
|
if(devices[i].title.length() > titleLength) { titleLength = devices[i].title.length(); }
|
||||||
|
if(devices[i].model.length() > modelLength) { modelLength = devices[i].model.length(); }
|
||||||
|
if(numDigits(devices[i].id) > idLength) { idLength = numDigits(devices[i].id); }
|
||||||
|
}
|
||||||
|
|
||||||
|
cout
|
||||||
|
<< "ID\033[" << (idLength - 2 + 2) << "C"
|
||||||
|
<< "Title\033[" << (titleLength - 5 + 2) << "C"
|
||||||
|
<< "Model" << endl;
|
||||||
|
|
||||||
|
for(int x = 0; x < (titleLength + modelLength + idLength + 4); x++) { cout << "-"; }
|
||||||
|
cout << endl;
|
||||||
|
|
||||||
|
for(int i = 0; i < devices.size(); i++)
|
||||||
|
{
|
||||||
|
cout
|
||||||
|
<< devices[i].id << "\033[" << (idLength - numDigits(devices[i].id) + 2) << "C"
|
||||||
|
<< devices[i].title << "\033[" << (titleLength - devices[i].title.length() + 2) << "C"
|
||||||
|
<< devices[i].model << endl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// device id
|
||||||
|
if(vm.count("id"))
|
||||||
|
{
|
||||||
|
id = vm["id"].as<int>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cout << desc << endl;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// load message
|
||||||
|
if(vm.count("pipe"))
|
||||||
|
{
|
||||||
|
message = "pipe";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stringstream lastArgument;
|
||||||
|
lastArgument << argv[argc-1];
|
||||||
|
|
||||||
|
message = lastArgument.str();
|
||||||
|
|
||||||
|
cout << message << endl;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// send the message
|
||||||
|
pusherInstance.sendToDevice(id, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ERROR HANDLING */
|
||||||
|
|
||||||
|
|
||||||
|
// errors thrown by pushhandler
|
||||||
|
catch(PusherError& e)
|
||||||
|
{
|
||||||
|
cout << "Error: " << e.what() << endl;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// boos ptree
|
||||||
|
catch(const boost::property_tree::ptree_error &e)
|
||||||
|
{
|
||||||
|
cout << "Maybe you need to request your appToken first?\n" << endl;
|
||||||
|
cout << e.what() << "\n" << desc << endl;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// other errors
|
||||||
|
catch(exception& e)
|
||||||
|
{
|
||||||
|
cout << "Some kind of error occured: " << e.what() << endl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
232
src/pushhandler.cpp
Normal file
232
src/pushhandler.cpp
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
#include "pushhandler.h"
|
||||||
|
|
||||||
|
#include <sstream>
|
||||||
|
#include <curl/curl.h>
|
||||||
|
#include <boost/format.hpp>
|
||||||
|
#include <boost/foreach.hpp>
|
||||||
|
#include <boost/property_tree/ptree.hpp>
|
||||||
|
#include <boost/property_tree/json_parser.hpp>
|
||||||
|
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
using namespace std;
|
||||||
|
|
||||||
|
// needed for handling curl output
|
||||||
|
static size_t WriteCallback(void *contents, size_t size, size_t nmemb, void *userp)
|
||||||
|
{
|
||||||
|
((std::string*)userp)->append((char*)contents, size * nmemb);
|
||||||
|
return size * nmemb;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// curl wrapper
|
||||||
|
string curlHandler(string data, const char* url)
|
||||||
|
{
|
||||||
|
CURL *curl;
|
||||||
|
CURLcode res;
|
||||||
|
string readBuffer;
|
||||||
|
curl = curl_easy_init();
|
||||||
|
|
||||||
|
if(curl)
|
||||||
|
{
|
||||||
|
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, USER_AGENT);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, data.c_str());
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &readBuffer);
|
||||||
|
res = curl_easy_perform(curl);
|
||||||
|
curl_easy_cleanup(curl);
|
||||||
|
|
||||||
|
if(res != CURLE_OK)
|
||||||
|
{
|
||||||
|
throw PusherError("Network Error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return readBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// constructor only username
|
||||||
|
PushHandler::PushHandler(string username)
|
||||||
|
{
|
||||||
|
this->username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// constructor with appToken
|
||||||
|
PushHandler::PushHandler(string username, string appToken)
|
||||||
|
{
|
||||||
|
this->username = username;
|
||||||
|
this->appToken = appToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// login
|
||||||
|
string PushHandler::login(string password)
|
||||||
|
{
|
||||||
|
// build request data
|
||||||
|
stringstream requestData;
|
||||||
|
requestData << boost::format("apiToken=%1%&username=%2%&password=%3%")
|
||||||
|
% API_TOKEN
|
||||||
|
% this->username
|
||||||
|
% password;
|
||||||
|
|
||||||
|
// network request
|
||||||
|
string readBuffer;
|
||||||
|
readBuffer = curlHandler(requestData.str(), URL_PN_LOGIN);
|
||||||
|
|
||||||
|
// json parsing
|
||||||
|
stringstream jsonData;
|
||||||
|
jsonData << readBuffer;
|
||||||
|
|
||||||
|
boost::property_tree::ptree pt;
|
||||||
|
boost::property_tree::read_json(jsonData, pt);
|
||||||
|
|
||||||
|
if(pt.get<string>("status") != "ok")
|
||||||
|
{
|
||||||
|
throw PusherError("wrong credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
this->appToken = pt.get<string>("appToken");
|
||||||
|
return this->appToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// get list of devices
|
||||||
|
vector<PushHandler::Device> PushHandler::getDevices()
|
||||||
|
{
|
||||||
|
// build request data
|
||||||
|
stringstream requestData;
|
||||||
|
requestData << boost::format("apiToken=%1%&appToken=%2%")
|
||||||
|
% API_TOKEN
|
||||||
|
% this->appToken;
|
||||||
|
|
||||||
|
// network request
|
||||||
|
string readBuffer;
|
||||||
|
readBuffer = curlHandler(requestData.str(), URL_PN_GET_DEVICES);
|
||||||
|
|
||||||
|
// json parsing
|
||||||
|
stringstream jsonData;
|
||||||
|
jsonData << readBuffer;
|
||||||
|
|
||||||
|
boost::property_tree::ptree pt;
|
||||||
|
boost::property_tree::read_json(jsonData, pt);
|
||||||
|
|
||||||
|
// handle the codes
|
||||||
|
switch(pt.get<int>("code"))
|
||||||
|
{
|
||||||
|
case 1: throw PusherError("Invalid API Token");
|
||||||
|
break;
|
||||||
|
case 2: throw PusherError("App Token missing");
|
||||||
|
break;
|
||||||
|
case 3: throw PusherError("App Token invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
vector<Device> buffer;
|
||||||
|
|
||||||
|
BOOST_FOREACH(boost::property_tree::ptree::value_type& v, pt.get_child("devices"))
|
||||||
|
{
|
||||||
|
Device buf;
|
||||||
|
|
||||||
|
buf.title = v.second.get<string>("title");
|
||||||
|
buf.id = v.second.get<int>("id");
|
||||||
|
buf.model = v.second.get<string>("model");
|
||||||
|
|
||||||
|
buffer.push_back(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// verify token
|
||||||
|
bool PushHandler::verifyToken()
|
||||||
|
{
|
||||||
|
// build request data
|
||||||
|
stringstream requestData;
|
||||||
|
requestData << boost::format("apiToken=%1%&username=%2%&appToken=%3%")
|
||||||
|
% API_TOKEN
|
||||||
|
% this->username
|
||||||
|
% this->appToken;
|
||||||
|
|
||||||
|
// network request
|
||||||
|
string readBuffer;
|
||||||
|
readBuffer = curlHandler(requestData.str(), URL_PN_CHECK_TOKEN);
|
||||||
|
|
||||||
|
// json parser
|
||||||
|
stringstream jsonData;
|
||||||
|
jsonData << readBuffer;
|
||||||
|
|
||||||
|
boost::property_tree::ptree pt;
|
||||||
|
boost::property_tree::read_json(jsonData, pt);
|
||||||
|
|
||||||
|
|
||||||
|
switch(pt.get<int>("code"))
|
||||||
|
{
|
||||||
|
case 0: return true;
|
||||||
|
break;
|
||||||
|
case 1: throw PusherError("Invalid API-Token");
|
||||||
|
break;
|
||||||
|
case 2: return false;
|
||||||
|
default: throw PusherError("Invalid server response");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// send to device
|
||||||
|
void PushHandler::sendToDevice(int id, string message)
|
||||||
|
{
|
||||||
|
// analyze content-type
|
||||||
|
// ......
|
||||||
|
|
||||||
|
|
||||||
|
// actual sending
|
||||||
|
// build request data
|
||||||
|
stringstream requestData;
|
||||||
|
requestData << boost::format("apiToken=%1%&appToken=%2%&app=%3%&deviceID=%4%&type=%5%&content=%6%")
|
||||||
|
% API_TOKEN
|
||||||
|
% this->appToken
|
||||||
|
% APP_PACKAGE
|
||||||
|
% id
|
||||||
|
% "MESSAGE"
|
||||||
|
% message;
|
||||||
|
|
||||||
|
// network request
|
||||||
|
string readBuffer;
|
||||||
|
readBuffer = curlHandler(requestData.str(), URL_PN_SEND_TO_DEVICE);
|
||||||
|
|
||||||
|
// json parsing
|
||||||
|
stringstream jsonData;
|
||||||
|
jsonData << readBuffer;
|
||||||
|
|
||||||
|
boost::property_tree::ptree pt;
|
||||||
|
boost::property_tree::read_json(jsonData, pt);
|
||||||
|
|
||||||
|
|
||||||
|
switch(pt.get<int>("id"))
|
||||||
|
{
|
||||||
|
case 0: //return 0;
|
||||||
|
break;
|
||||||
|
case 1: throw PusherError("Invalid API Token");
|
||||||
|
break;
|
||||||
|
case 2: throw PusherError("App Token missing");
|
||||||
|
break;
|
||||||
|
case 3: throw PusherError("App Token invalid");
|
||||||
|
break;
|
||||||
|
case 4: throw PusherError("Package Name missing");
|
||||||
|
break;
|
||||||
|
case 5: throw PusherError("Package Name invalid");
|
||||||
|
break;
|
||||||
|
case 6: throw PusherError("Package Name is not linked with the provided API Token");
|
||||||
|
break;
|
||||||
|
case 7: throw PusherError("Device ID missing");
|
||||||
|
break;
|
||||||
|
case 8: throw PusherError("Device ID invalid");
|
||||||
|
break;
|
||||||
|
case 9: throw PusherError("Type missing or invalid");
|
||||||
|
break;
|
||||||
|
default: throw PusherError("Invalid server response");
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/pushhandler.h
Normal file
59
src/pushhandler.h
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
#ifndef H_PUSHHANDLER
|
||||||
|
#define H_PUSHHANDLER
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
|
#define API_TOKEN "1234" // change this line
|
||||||
|
|
||||||
|
#define APP_PACKAGE "com.hackherz.pusher"
|
||||||
|
#define USER_AGENT "pusher/0.2"
|
||||||
|
|
||||||
|
#define URL_PN_LOGIN "http://a.pushnotifier.de/1/login/"
|
||||||
|
#define URL_PN_CHECK_TOKEN "http://a.pushnotifier.de/1/checkToken/"
|
||||||
|
#define URL_PN_GET_DEVICES "http://a.pushnotifier.de/1/getDevices/"
|
||||||
|
#define URL_PN_SEND_TO_DEVICE "http://a.pushnotifier.de/1/sendToDevice/"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class PushHandler
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
PushHandler(std::string username);
|
||||||
|
PushHandler(std::string username, std::string appToken);
|
||||||
|
|
||||||
|
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
std::string title;
|
||||||
|
int id;
|
||||||
|
std::string model;
|
||||||
|
} Device;
|
||||||
|
|
||||||
|
|
||||||
|
std::string login(std::string password);
|
||||||
|
std::vector<Device> getDevices();
|
||||||
|
void sendToDevice(int deviceID, std::string message);
|
||||||
|
bool verifyToken();
|
||||||
|
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string username;
|
||||||
|
std::string appToken;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// class for exceptions
|
||||||
|
class PusherError
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
PusherError(std::string content) { this->content = content; }
|
||||||
|
std::string what() { return this->content; }
|
||||||
|
private:
|
||||||
|
std::string content;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue