mirror of https://github.com/vitalif/grive2
Compare commits
14 Commits
fc65d815f7
...
8e0f3c630b
Author | SHA1 | Date |
---|---|---|
Tatsh | 8e0f3c630b | |
Vitaliy Filippov | 648ff8eea1 | |
Vitaliy Filippov | eb82bfe28b | |
Vitaliy Filippov | f9e9fe510d | |
Vitaliy Filippov | ae38035ef4 | |
Christoph Junghans | b788284020 | |
Kilian von Pflugk | cd4665ae1b | |
Vitaliy Filippov | d03c4a24ce | |
Vitaliy Filippov | 328987ec34 | |
Vitaliy Filippov | 6645206d27 | |
Jasper Young | 5c8e87ee9a | |
Christoph Junghans | 3cf1c058a3 | |
Vitaliy Filippov | 6901fbb169 | |
ncaq | 48f5f0e52f |
|
@ -1,4 +1,5 @@
|
||||||
.git
|
*
|
||||||
Dockerfile
|
!cmake
|
||||||
.dockerignore
|
!grive
|
||||||
.gitignore
|
!libgrive
|
||||||
|
!CMakeLists.txt
|
||||||
|
|
|
@ -4,7 +4,7 @@ project(grive2)
|
||||||
include(GNUInstallDirs)
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
# Grive version. remember to update it for every new release!
|
# Grive version. remember to update it for every new release!
|
||||||
set( GRIVE_VERSION "0.5.2-dev" CACHE STRING "Grive version" )
|
set( GRIVE_VERSION "0.5.3" CACHE STRING "Grive version" )
|
||||||
message(WARNING "Version to build: ${GRIVE_VERSION}")
|
message(WARNING "Version to build: ${GRIVE_VERSION}")
|
||||||
|
|
||||||
# common compile options
|
# common compile options
|
||||||
|
|
36
Dockerfile
36
Dockerfile
|
@ -1,27 +1,25 @@
|
||||||
FROM alpine:3.7 as build
|
FROM alpine:3.7 as build
|
||||||
|
|
||||||
RUN apk add git make cmake g++ libgcrypt-dev yajl-dev yajl \
|
RUN apk add make cmake g++ libgcrypt-dev yajl-dev yajl \
|
||||||
boost-dev curl-dev expat-dev cppunit-dev binutils-dev \
|
boost-dev curl-dev expat-dev cppunit-dev binutils-dev \
|
||||||
pkgconfig \
|
pkgconfig
|
||||||
&& git clone https://github.com/vitalif/grive2.git \
|
|
||||||
&& mkdir grive2/build \
|
ADD . /grive2
|
||||||
&& cd grive2/build \
|
|
||||||
&& cmake .. \
|
RUN mkdir /grive2/build \
|
||||||
&& make -j4 \
|
&& cd /grive2/build \
|
||||||
&& make install \
|
&& cmake .. \
|
||||||
&& cd ../.. \
|
&& make -j4 install
|
||||||
&& rm -rf grive2 \
|
|
||||||
&& mkdir /drive
|
|
||||||
|
|
||||||
FROM alpine:3.7
|
FROM alpine:3.7
|
||||||
COPY --from=build /usr/local/bin/grive /bin/grive
|
|
||||||
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.1/dumb-init_1.2.1_amd64 /bin/dumb-init
|
RUN apk add yajl libcurl libgcrypt boost-program_options boost-regex libstdc++ boost-system \
|
||||||
RUN chmod 777 /bin/dumb-init /bin/grive \
|
|
||||||
&& mkdir /data \
|
|
||||||
&& apk add yajl-dev curl-dev libgcrypt \
|
|
||||||
boost-program_options boost-regex libstdc++ boost-system boost-dev binutils-dev \
|
|
||||||
&& apk add boost-filesystem --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main
|
&& apk add boost-filesystem --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main
|
||||||
|
|
||||||
|
COPY --from=build /usr/local/bin/grive /bin/grive
|
||||||
|
RUN chmod 777 /bin/grive \
|
||||||
|
&& mkdir /data
|
||||||
|
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
ENTRYPOINT ["dumb-init", "grive"]
|
ENTRYPOINT grive
|
||||||
|
|
15
README.md
15
README.md
|
@ -1,6 +1,6 @@
|
||||||
# Grive2 0.5.2-dev
|
# Grive2 0.5.3
|
||||||
|
|
||||||
13 Nov 2019, Vitaliy Filippov
|
09 Nov 2022, Vitaliy Filippov
|
||||||
|
|
||||||
http://yourcmc.ru/wiki/Grive2
|
http://yourcmc.ru/wiki/Grive2
|
||||||
|
|
||||||
|
@ -39,10 +39,10 @@ grive -a
|
||||||
|
|
||||||
A URL should be printed. Go to the link. You will need to login to your Google
|
A URL should be printed. Go to the link. You will need to login to your Google
|
||||||
account if you haven't done so. After granting the permission to Grive, the
|
account if you haven't done so. After granting the permission to Grive, the
|
||||||
browser will show you an authenication code. Copy-and-paste that to the
|
authorization code will be forwarded to the Grive application and you will be
|
||||||
standard input of Grive.
|
redirected to a localhost web page confirming the authorization.
|
||||||
|
|
||||||
If everything works fine, Grive will create .grive and .grive_state files in your
|
If everything works fine, Grive will create .grive and .grive\_state files in your
|
||||||
current directory. It will also start downloading files from your Google Drive to
|
current directory. It will also start downloading files from your Google Drive to
|
||||||
your current directory.
|
your current directory.
|
||||||
|
|
||||||
|
@ -203,7 +203,10 @@ Alternativly you can define your own client_id and client_secret during build
|
||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
|
|
||||||
### Grive2 v0.5.2-dev
|
### Grive2 v0.5.3
|
||||||
|
|
||||||
|
- Implement Google OAuth loopback IP redirect flow
|
||||||
|
- Various small fixes
|
||||||
|
|
||||||
### Grive2 v0.5.1
|
### Grive2 v0.5.1
|
||||||
|
|
||||||
|
|
|
@ -27,6 +27,9 @@ IF(LIBGCRYPTCONFIG_EXECUTABLE)
|
||||||
|
|
||||||
EXEC_PROGRAM(${LIBGCRYPTCONFIG_EXECUTABLE} ARGS --cflags RETURN_VALUE _return_VALUE OUTPUT_VARIABLE LIBGCRYPT_CFLAGS)
|
EXEC_PROGRAM(${LIBGCRYPTCONFIG_EXECUTABLE} ARGS --cflags RETURN_VALUE _return_VALUE OUTPUT_VARIABLE LIBGCRYPT_CFLAGS)
|
||||||
|
|
||||||
|
string(REPLACE "fgrep: warning: fgrep is obsolescent; using grep -F" "" LIBGCRYPT_LIBRARIES "${LIBGCRYPT_LIBRARIES}")
|
||||||
|
string(STRIP "${LIBGCRYPT_LIBRARIES}" LIBGCRYPT_LIBRARIES)
|
||||||
|
|
||||||
IF(${LIBGCRYPT_CFLAGS} MATCHES "\n")
|
IF(${LIBGCRYPT_CFLAGS} MATCHES "\n")
|
||||||
SET(LIBGCRYPT_CFLAGS " ")
|
SET(LIBGCRYPT_CFLAGS " ")
|
||||||
ENDIF(${LIBGCRYPT_CFLAGS} MATCHES "\n")
|
ENDIF(${LIBGCRYPT_CFLAGS} MATCHES "\n")
|
||||||
|
|
|
@ -1,3 +1,10 @@
|
||||||
|
grive2 (0.5.3) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Implement Google OAuth loopback IP redirect flow
|
||||||
|
* Various small fixes
|
||||||
|
|
||||||
|
-- Vitaliy Filippov <vitalif@yourcmc.ru> Wed, 09 Nov 2022 12:42:28 +0300
|
||||||
|
|
||||||
grive2 (0.5.2+git20210315) unstable; urgency=medium
|
grive2 (0.5.2+git20210315) unstable; urgency=medium
|
||||||
|
|
||||||
* Newer dev version
|
* Newer dev version
|
||||||
|
|
|
@ -111,9 +111,9 @@ int Main( int argc, char **argv )
|
||||||
( "help,h", "Produce help message" )
|
( "help,h", "Produce help message" )
|
||||||
( "version,v", "Display Grive version" )
|
( "version,v", "Display Grive version" )
|
||||||
( "auth,a", "Request authorization token" )
|
( "auth,a", "Request authorization token" )
|
||||||
( "id,i", po::value<std::string>(), "Authentication ID")
|
( "id,i", po::value<std::string>(), "Authentication ID")
|
||||||
( "secret,e", po::value<std::string>(), "Authentication secret")
|
( "secret,e", po::value<std::string>(), "Authentication secret")
|
||||||
( "print-url", "Only print url for request")
|
( "print-url", "Only print url for request")
|
||||||
( "path,p", po::value<std::string>(), "Path to working copy root")
|
( "path,p", po::value<std::string>(), "Path to working copy root")
|
||||||
( "dir,s", po::value<std::string>(), "Single subdirectory to sync")
|
( "dir,s", po::value<std::string>(), "Single subdirectory to sync")
|
||||||
( "verbose,V", "Verbose mode. Enable more messages than normal.")
|
( "verbose,V", "Verbose mode. Enable more messages than normal.")
|
||||||
|
@ -185,34 +185,32 @@ int Main( int argc, char **argv )
|
||||||
: default_secret ;
|
: default_secret ;
|
||||||
|
|
||||||
OAuth2 token( http.get(), id, secret ) ;
|
OAuth2 token( http.get(), id, secret ) ;
|
||||||
|
|
||||||
if ( vm.count("print-url") )
|
if ( vm.count("print-url") )
|
||||||
{
|
{
|
||||||
std::cout << token.MakeAuthURL() << std::endl ;
|
std::cout << token.MakeAuthURL() << std::endl ;
|
||||||
return 0 ;
|
return 0 ;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::cout
|
std::cout
|
||||||
<< "-----------------------\n"
|
<< "-----------------------\n"
|
||||||
<< "Please go to this URL and get an authentication code:\n\n"
|
<< "Please open this URL in your browser to authenticate Grive2:\n\n"
|
||||||
<< token.MakeAuthURL()
|
<< token.MakeAuthURL()
|
||||||
<< std::endl ;
|
<< std::endl ;
|
||||||
|
|
||||||
std::cout
|
if ( !token.GetCode() )
|
||||||
<< "\n-----------------------\n"
|
{
|
||||||
<< "Please input the authentication code here: " << std::endl ;
|
std::cout << "Authentication failed\n";
|
||||||
std::string code ;
|
return -1;
|
||||||
std::cin >> code ;
|
}
|
||||||
|
|
||||||
token.Auth( code ) ;
|
|
||||||
|
|
||||||
// save to config
|
// save to config
|
||||||
config.Set( "id", Val( id ) ) ;
|
config.Set( "id", Val( id ) ) ;
|
||||||
config.Set( "secret", Val( secret ) ) ;
|
config.Set( "secret", Val( secret ) ) ;
|
||||||
config.Set( "refresh_token", Val( token.RefreshToken() ) ) ;
|
config.Set( "refresh_token", Val( token.RefreshToken() ) ) ;
|
||||||
config.Save() ;
|
config.Save() ;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string refresh_token ;
|
std::string refresh_token ;
|
||||||
std::string id ;
|
std::string id ;
|
||||||
std::string secret ;
|
std::string secret ;
|
||||||
|
@ -231,7 +229,7 @@ int Main( int argc, char **argv )
|
||||||
|
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
OAuth2 token( http.get(), refresh_token, id, secret ) ;
|
OAuth2 token( http.get(), refresh_token, id, secret ) ;
|
||||||
AuthAgent agent( token, http.get() ) ;
|
AuthAgent agent( token, http.get() ) ;
|
||||||
v2::Syncer2 syncer( &agent );
|
v2::Syncer2 syncer( &agent );
|
||||||
|
|
|
@ -21,6 +21,7 @@
|
||||||
|
|
||||||
#include "util/FileSystem.hh"
|
#include "util/FileSystem.hh"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <iosfwd>
|
#include <iosfwd>
|
||||||
|
|
|
@ -25,6 +25,13 @@
|
||||||
#include "http/Header.hh"
|
#include "http/Header.hh"
|
||||||
#include "util/log/Log.hh"
|
#include "util/log/Log.hh"
|
||||||
|
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <poll.h>
|
||||||
|
|
||||||
// for debugging
|
// for debugging
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
|
||||||
|
@ -50,18 +57,29 @@ OAuth2::OAuth2(
|
||||||
const std::string& client_id,
|
const std::string& client_id,
|
||||||
const std::string& client_secret ) :
|
const std::string& client_secret ) :
|
||||||
m_agent( agent ),
|
m_agent( agent ),
|
||||||
|
m_port( 0 ),
|
||||||
|
m_socket( -1 ),
|
||||||
m_client_id( client_id ),
|
m_client_id( client_id ),
|
||||||
m_client_secret( client_secret )
|
m_client_secret( client_secret )
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
void OAuth2::Auth( const std::string& auth_code )
|
OAuth2::~OAuth2()
|
||||||
|
{
|
||||||
|
if ( m_socket >= 0 )
|
||||||
|
{
|
||||||
|
close( m_socket );
|
||||||
|
m_socket = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OAuth2::Auth( const std::string& auth_code )
|
||||||
{
|
{
|
||||||
std::string post =
|
std::string post =
|
||||||
"code=" + auth_code +
|
"code=" + auth_code +
|
||||||
"&client_id=" + m_client_id +
|
"&client_id=" + m_client_id +
|
||||||
"&client_secret=" + m_client_secret +
|
"&client_secret=" + m_client_secret +
|
||||||
"&redirect_uri=" + "urn:ietf:wg:oauth:2.0:oob" +
|
"&redirect_uri=http%3A%2F%2Flocalhost:" + std::to_string( m_port ) + "%2Fauth" +
|
||||||
"&grant_type=authorization_code" ;
|
"&grant_type=authorization_code" ;
|
||||||
|
|
||||||
http::ValResponse resp ;
|
http::ValResponse resp ;
|
||||||
|
@ -77,19 +95,120 @@ void OAuth2::Auth( const std::string& auth_code )
|
||||||
{
|
{
|
||||||
Log( "Failed to obtain auth token: HTTP %1%, body: %2%",
|
Log( "Failed to obtain auth token: HTTP %1%, body: %2%",
|
||||||
code, m_agent->LastError(), log::error ) ;
|
code, m_agent->LastError(), log::error ) ;
|
||||||
BOOST_THROW_EXCEPTION( AuthFailed() );
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string OAuth2::MakeAuthURL()
|
std::string OAuth2::MakeAuthURL()
|
||||||
{
|
{
|
||||||
|
if ( !m_port )
|
||||||
|
{
|
||||||
|
sockaddr_storage addr = { 0 };
|
||||||
|
addr.ss_family = AF_INET;
|
||||||
|
m_socket = socket( AF_INET, SOCK_STREAM, 0 );
|
||||||
|
if ( m_socket < 0 )
|
||||||
|
throw std::runtime_error( std::string("socket: ") + strerror(errno) );
|
||||||
|
if ( bind( m_socket, (sockaddr*)&addr, sizeof( addr ) ) < 0 )
|
||||||
|
{
|
||||||
|
close( m_socket );
|
||||||
|
m_socket = -1;
|
||||||
|
throw std::runtime_error( std::string("bind: ") + strerror(errno) );
|
||||||
|
}
|
||||||
|
socklen_t len = sizeof( addr );
|
||||||
|
if ( getsockname( m_socket, (sockaddr *)&addr, &len ) == -1 )
|
||||||
|
{
|
||||||
|
close( m_socket );
|
||||||
|
m_socket = -1;
|
||||||
|
throw std::runtime_error( std::string("getsockname: ") + strerror(errno) );
|
||||||
|
}
|
||||||
|
m_port = ntohs(((sockaddr_in*)&addr)->sin_port);
|
||||||
|
if ( listen( m_socket, 128 ) < 0 )
|
||||||
|
{
|
||||||
|
close( m_socket );
|
||||||
|
m_socket = -1;
|
||||||
|
m_port = 0;
|
||||||
|
throw std::runtime_error( std::string("listen: ") + strerror(errno) );
|
||||||
|
}
|
||||||
|
}
|
||||||
return "https://accounts.google.com/o/oauth2/auth"
|
return "https://accounts.google.com/o/oauth2/auth"
|
||||||
"?scope=" + m_agent->Escape( "https://www.googleapis.com/auth/drive" ) +
|
"?scope=" + m_agent->Escape( "https://www.googleapis.com/auth/drive" ) +
|
||||||
"&redirect_uri=urn:ietf:wg:oauth:2.0:oob"
|
"&redirect_uri=http%3A%2F%2Flocalhost:" + std::to_string( m_port ) + "%2Fauth" +
|
||||||
"&response_type=code"
|
"&response_type=code"
|
||||||
"&client_id=" + m_client_id ;
|
"&client_id=" + m_client_id ;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool OAuth2::GetCode( )
|
||||||
|
{
|
||||||
|
sockaddr_storage addr = { 0 };
|
||||||
|
int peer_fd = -1;
|
||||||
|
while ( peer_fd < 0 )
|
||||||
|
{
|
||||||
|
socklen_t peer_addr_size = sizeof( addr );
|
||||||
|
peer_fd = accept( m_socket, (sockaddr*)&addr, &peer_addr_size );
|
||||||
|
if ( peer_fd == -1 && errno != EAGAIN && errno != EINTR )
|
||||||
|
throw std::runtime_error( std::string("accept: ") + strerror(errno) );
|
||||||
|
}
|
||||||
|
fcntl( peer_fd, F_SETFL, fcntl( peer_fd, F_GETFL, 0 ) | O_NONBLOCK );
|
||||||
|
struct pollfd pfd = (struct pollfd){
|
||||||
|
.fd = peer_fd,
|
||||||
|
.events = POLLIN|POLLRDHUP,
|
||||||
|
};
|
||||||
|
char buf[4096];
|
||||||
|
std::string request;
|
||||||
|
while ( true )
|
||||||
|
{
|
||||||
|
pfd.revents = 0;
|
||||||
|
poll( &pfd, 1, -1 );
|
||||||
|
if ( pfd.revents & POLLRDHUP )
|
||||||
|
break;
|
||||||
|
int r = 1;
|
||||||
|
while ( r > 0 )
|
||||||
|
{
|
||||||
|
r = read( peer_fd, buf, sizeof( buf ) );
|
||||||
|
if ( r > 0 )
|
||||||
|
request += std::string( buf, r );
|
||||||
|
else if ( r == 0 )
|
||||||
|
break;
|
||||||
|
else if ( errno != EAGAIN && errno != EINTR )
|
||||||
|
throw std::runtime_error( std::string("read: ") + strerror(errno) );
|
||||||
|
}
|
||||||
|
if ( r == 0 || ( r < 0 && request.find( "\n" ) > 0 ) ) // GET ... HTTP/1.1\r\n
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
bool ok = false;
|
||||||
|
if ( request.substr( 0, 10 ) == "GET /auth?" )
|
||||||
|
{
|
||||||
|
std::string line = request;
|
||||||
|
int p = line.find( "\n" );
|
||||||
|
if ( p > 0 )
|
||||||
|
line = line.substr( 0, p );
|
||||||
|
p = line.rfind( " " );
|
||||||
|
if ( p > 0 )
|
||||||
|
line = line.substr( 0, p );
|
||||||
|
p = line.find( "code=" );
|
||||||
|
if ( p > 0 )
|
||||||
|
line = line.substr( p+5 );
|
||||||
|
p = line.find( "&" );
|
||||||
|
if ( p > 0 )
|
||||||
|
line = line.substr( 0, p );
|
||||||
|
ok = Auth( line );
|
||||||
|
}
|
||||||
|
std::string response = ( ok
|
||||||
|
? "Authenticated successfully. Please close the page"
|
||||||
|
: "Authentication error. Please try again" );
|
||||||
|
response = "HTTP/1.1 200 OK\r\n"
|
||||||
|
"Content-Type: text/html; charset=utf-8\r\n"
|
||||||
|
"Connection: close\r\n"
|
||||||
|
"\r\n"+
|
||||||
|
response+
|
||||||
|
"\r\n";
|
||||||
|
write( peer_fd, response.c_str(), response.size() );
|
||||||
|
close( peer_fd );
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
void OAuth2::Refresh( )
|
void OAuth2::Refresh( )
|
||||||
{
|
{
|
||||||
std::string post =
|
std::string post =
|
||||||
|
|
|
@ -41,13 +41,15 @@ public :
|
||||||
const std::string& refresh_code,
|
const std::string& refresh_code,
|
||||||
const std::string& client_id,
|
const std::string& client_id,
|
||||||
const std::string& client_secret ) ;
|
const std::string& client_secret ) ;
|
||||||
|
~OAuth2( ) ;
|
||||||
|
|
||||||
std::string Str() const ;
|
std::string Str() const ;
|
||||||
|
|
||||||
std::string MakeAuthURL() ;
|
std::string MakeAuthURL() ;
|
||||||
|
|
||||||
void Auth( const std::string& auth_code ) ;
|
bool Auth( const std::string& auth_code ) ;
|
||||||
void Refresh( ) ;
|
void Refresh( ) ;
|
||||||
|
bool GetCode( ) ;
|
||||||
|
|
||||||
std::string RefreshToken( ) const ;
|
std::string RefreshToken( ) const ;
|
||||||
std::string AccessToken( ) const ;
|
std::string AccessToken( ) const ;
|
||||||
|
@ -59,7 +61,9 @@ private :
|
||||||
std::string m_access ;
|
std::string m_access ;
|
||||||
std::string m_refresh ;
|
std::string m_refresh ;
|
||||||
http::Agent* m_agent ;
|
http::Agent* m_agent ;
|
||||||
|
int m_port ;
|
||||||
|
int m_socket ;
|
||||||
|
|
||||||
const std::string m_client_id ;
|
const std::string m_client_id ;
|
||||||
const std::string m_client_secret ;
|
const std::string m_client_secret ;
|
||||||
} ;
|
} ;
|
||||||
|
|
|
@ -84,7 +84,7 @@ void Config::Save( )
|
||||||
|
|
||||||
void Config::Set( const std::string& key, const Val& value )
|
void Config::Set( const std::string& key, const Val& value )
|
||||||
{
|
{
|
||||||
m_file.Add( key, value ) ;
|
m_file.Set( key, value ) ;
|
||||||
}
|
}
|
||||||
|
|
||||||
Val Config::Get( const std::string& key ) const
|
Val Config::Get( const std::string& key ) const
|
||||||
|
|
|
@ -23,5 +23,5 @@ install(
|
||||||
PROGRAMS
|
PROGRAMS
|
||||||
grive-sync.sh
|
grive-sync.sh
|
||||||
DESTINATION
|
DESTINATION
|
||||||
${CMAKE_INSTALL_FULL_LIBDIR}/grive
|
${CMAKE_INSTALL_FULL_LIBEXECDIR}/grive
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in New Issue