mirror of
https://github.com/abraunegg/onedrive
synced 2024-06-27 18:00:36 +02:00
446 lines
13 KiB
D
446 lines
13 KiB
D
import std.net.curl: CurlException, HTTP;
|
|
import std.datetime, std.exception, std.file, std.json, std.path;
|
|
import std.stdio, std.string, std.uni, std.uri;
|
|
import config;
|
|
static import log;
|
|
|
|
|
|
private immutable {
|
|
string clientId = "22c49a0d-d21c-4792-aed1-8f163c982546";
|
|
string authUrl = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
|
|
string redirectUrl = "https://login.microsoftonline.com/common/oauth2/nativeclient";
|
|
string tokenUrl = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
|
|
string driveUrl = "https://graph.microsoft.com/v1.0/me/drive";
|
|
string itemByIdUrl = "https://graph.microsoft.com/v1.0/me/drive/items/";
|
|
string itemByPathUrl = "https://graph.microsoft.com/v1.0/me/drive/root:/";
|
|
string driveByIdUrl = "https://graph.microsoft.com/v1.0/drives/";
|
|
}
|
|
|
|
class OneDriveException: Exception
|
|
{
|
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/concepts/errors
|
|
int httpStatusCode;
|
|
JSONValue error;
|
|
|
|
@safe pure this(int httpStatusCode, string reason, string file = __FILE__, size_t line = __LINE__)
|
|
{
|
|
this.httpStatusCode = httpStatusCode;
|
|
this.error = error;
|
|
string msg = format("HTTP request returned status code %d (%s)", httpStatusCode, reason);
|
|
super(msg, file, line);
|
|
}
|
|
|
|
this(int httpStatusCode, string reason, ref const JSONValue error, string file = __FILE__, size_t line = __LINE__)
|
|
{
|
|
this.httpStatusCode = httpStatusCode;
|
|
this.error = error;
|
|
string msg = format("HTTP request returned status code %d (%s)\n%s", httpStatusCode, reason, toJSON(error, true));
|
|
super(msg, file, line);
|
|
}
|
|
}
|
|
|
|
final class OneDriveApi
|
|
{
|
|
private Config cfg;
|
|
private string refreshToken, accessToken;
|
|
private SysTime accessTokenExpiration;
|
|
/* private */ HTTP http;
|
|
|
|
// if true, every new access token is printed
|
|
bool printAccessToken;
|
|
|
|
this(Config cfg)
|
|
{
|
|
this.cfg = cfg;
|
|
http = HTTP();
|
|
//http.verbose = true;
|
|
}
|
|
|
|
bool init()
|
|
{
|
|
try {
|
|
refreshToken = readText(cfg.refreshTokenFilePath);
|
|
} catch (FileException e) {
|
|
return authorize();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool authorize()
|
|
{
|
|
import std.stdio, std.regex;
|
|
char[] response;
|
|
string url = authUrl ~ "?client_id=" ~ clientId ~ "&scope=files.readwrite%20files.readwrite.all%20offline_access&response_type=code&redirect_uri=" ~ redirectUrl;
|
|
log.log("Authorize this app visiting:\n");
|
|
write(url, "\n\n", "Enter the response uri: ");
|
|
readln(response);
|
|
// match the authorization code
|
|
auto c = matchFirst(response, r"(?:[\?&]code=)([\w\d-]+)");
|
|
if (c.empty) {
|
|
log.log("Invalid uri");
|
|
return false;
|
|
}
|
|
c.popFront(); // skip the whole match
|
|
redeemToken(c.front);
|
|
return true;
|
|
}
|
|
|
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/drive_get
|
|
JSONValue getDefaultDrive()
|
|
{
|
|
checkAccessTokenExpired();
|
|
return get(driveUrl);
|
|
}
|
|
|
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_get
|
|
JSONValue getDefaultRoot()
|
|
{
|
|
checkAccessTokenExpired();
|
|
return get(driveUrl ~ "/root");
|
|
}
|
|
|
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_delta
|
|
JSONValue viewChangesById(const(char)[] driveId, const(char)[] id, const(char)[] deltaLink)
|
|
{
|
|
checkAccessTokenExpired();
|
|
const(char)[] url = deltaLink;
|
|
if (url == null) {
|
|
url = driveByIdUrl ~ driveId ~ "/items/" ~ id ~ "/delta";
|
|
url ~= "?select=id,name,eTag,cTag,deleted,file,folder,root,fileSystemInfo,remoteItem,parentReference";
|
|
}
|
|
return get(url);
|
|
}
|
|
|
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_delta
|
|
JSONValue viewChangesByPath(const(char)[] path, const(char)[] deltaLink)
|
|
{
|
|
checkAccessTokenExpired();
|
|
const(char)[] url = deltaLink;
|
|
if (url == null) {
|
|
if (path == ".") url = driveUrl ~ "/root/delta";
|
|
else url = itemByPathUrl ~ encodeComponent(path) ~ ":/delta";
|
|
url ~= "?select=id,name,eTag,cTag,deleted,file,folder,root,fileSystemInfo,remoteItem,parentReference";
|
|
}
|
|
return get(url);
|
|
}
|
|
|
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_get_content
|
|
void downloadById(const(char)[] driveId, const(char)[] id, string saveToPath)
|
|
{
|
|
checkAccessTokenExpired();
|
|
scope(failure) {
|
|
if (exists(saveToPath)) remove(saveToPath);
|
|
}
|
|
mkdirRecurse(dirName(saveToPath));
|
|
const(char)[] url = driveByIdUrl ~ driveId ~ "/items/" ~ id ~ "/content?AVOverride=1";
|
|
download(url, saveToPath);
|
|
}
|
|
|
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_put_content
|
|
JSONValue simpleUpload(string localPath, string parentDriveId, string parentId, string filename, const(char)[] eTag = null)
|
|
{
|
|
checkAccessTokenExpired();
|
|
string url = driveByIdUrl ~ parentDriveId ~ "/items/" ~ parentId ~ ":/" ~ encodeComponent(filename) ~ ":/content";
|
|
// TODO: investigate why this fails for remote folders
|
|
//if (eTag) http.addRequestHeader("If-Match", eTag);
|
|
/*else http.addRequestHeader("If-None-Match", "*");*/
|
|
return upload(localPath, url);
|
|
}
|
|
|
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_put_content
|
|
JSONValue simpleUploadReplace(string localPath, string driveId, string id, const(char)[] eTag = null)
|
|
{
|
|
checkAccessTokenExpired();
|
|
string url = driveByIdUrl ~ driveId ~ "/items/" ~ id ~ "/content";
|
|
if (eTag) http.addRequestHeader("If-Match", eTag);
|
|
return upload(localPath, url);
|
|
}
|
|
|
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_update
|
|
JSONValue updateById(const(char)[] driveId, const(char)[] id, JSONValue data, const(char)[] eTag = null)
|
|
{
|
|
checkAccessTokenExpired();
|
|
const(char)[] url = driveByIdUrl ~ driveId ~ "/items/" ~ id;
|
|
if (eTag) http.addRequestHeader("If-Match", eTag);
|
|
http.addRequestHeader("Content-Type", "application/json");
|
|
return patch(url, data.toString());
|
|
}
|
|
|
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_delete
|
|
void deleteById(const(char)[] driveId, const(char)[] id, const(char)[] eTag = null)
|
|
{
|
|
checkAccessTokenExpired();
|
|
const(char)[] url = driveByIdUrl ~ driveId ~ "/items/" ~ id;
|
|
//TODO: investigate why this always fail with 412 (Precondition Failed)
|
|
//if (eTag) http.addRequestHeader("If-Match", eTag);
|
|
del(url);
|
|
}
|
|
|
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_post_children
|
|
JSONValue createById(const(char)[] parentDriveId, const(char)[] parentId, JSONValue item)
|
|
{
|
|
checkAccessTokenExpired();
|
|
const(char)[] url = driveByIdUrl ~ parentDriveId ~ "/items/" ~ parentId ~ "/children";
|
|
http.addRequestHeader("Content-Type", "application/json");
|
|
return post(url, item.toString());
|
|
}
|
|
|
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_createuploadsession
|
|
JSONValue createUploadSession(const(char)[] parentDriveId, const(char)[] parentId, const(char)[] filename, const(char)[] eTag = null)
|
|
{
|
|
checkAccessTokenExpired();
|
|
const(char)[] url = driveByIdUrl ~ parentDriveId ~ "/items/" ~ parentId ~ ":/" ~ encodeComponent(filename) ~ ":/createUploadSession";
|
|
if (eTag) http.addRequestHeader("If-Match", eTag);
|
|
return post(url, null);
|
|
}
|
|
|
|
// https://dev.onedrive.com/items/upload_large_files.htm
|
|
JSONValue uploadFragment(const(char)[] uploadUrl, string filepath, long offset, long offsetSize, long fileSize)
|
|
{
|
|
checkAccessTokenExpired();
|
|
scope(exit) {
|
|
http.clearRequestHeaders();
|
|
http.onSend = null;
|
|
}
|
|
http.method = HTTP.Method.put;
|
|
http.url = uploadUrl;
|
|
// when using microsoft graph the auth code is different
|
|
//addAccessTokenHeader();
|
|
import std.conv;
|
|
string contentRange = "bytes " ~ to!string(offset) ~ "-" ~ to!string(offset + offsetSize - 1) ~ "/" ~ to!string(fileSize);
|
|
http.addRequestHeader("Content-Range", contentRange);
|
|
auto file = File(filepath, "rb");
|
|
file.seek(offset);
|
|
http.onSend = data => file.rawRead(data).length;
|
|
http.contentLength = offsetSize;
|
|
auto response = perform();
|
|
// TODO: retry on 5xx errors
|
|
checkHttpCode();
|
|
return response;
|
|
}
|
|
|
|
// https://dev.onedrive.com/items/upload_large_files.htm
|
|
JSONValue requestUploadStatus(const(char)[] uploadUrl)
|
|
{
|
|
checkAccessTokenExpired();
|
|
// when using microsoft graph the auth code is different
|
|
return get(uploadUrl, true);
|
|
}
|
|
|
|
private void redeemToken(const(char)[] authCode)
|
|
{
|
|
const(char)[] postData =
|
|
"client_id=" ~ clientId ~
|
|
"&redirect_uri=" ~ redirectUrl ~
|
|
"&code=" ~ authCode ~
|
|
"&grant_type=authorization_code";
|
|
acquireToken(postData);
|
|
}
|
|
|
|
private void newToken()
|
|
{
|
|
string postData =
|
|
"client_id=" ~ clientId ~
|
|
"&redirect_uri=" ~ redirectUrl ~
|
|
"&refresh_token=" ~ refreshToken ~
|
|
"&grant_type=refresh_token";
|
|
acquireToken(postData);
|
|
}
|
|
|
|
private void acquireToken(const(char)[] postData)
|
|
{
|
|
JSONValue response = post(tokenUrl, postData);
|
|
accessToken = "bearer " ~ response["access_token"].str();
|
|
refreshToken = response["refresh_token"].str();
|
|
accessTokenExpiration = Clock.currTime() + dur!"seconds"(response["expires_in"].integer());
|
|
std.file.write(cfg.refreshTokenFilePath, refreshToken);
|
|
if (printAccessToken) writeln("New access token: ", accessToken);
|
|
}
|
|
|
|
private void checkAccessTokenExpired()
|
|
{
|
|
try {
|
|
if (Clock.currTime() >= accessTokenExpiration) {
|
|
newToken();
|
|
}
|
|
} catch (OneDriveException e) {
|
|
if (e.httpStatusCode == 400 || e.httpStatusCode == 401) {
|
|
e.msg ~= "\nRefresh token invalid, use --logout to authorize the client again";
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
private void addAccessTokenHeader()
|
|
{
|
|
http.addRequestHeader("Authorization", accessToken);
|
|
}
|
|
|
|
private JSONValue get(const(char)[] url, bool skipToken = false)
|
|
{
|
|
scope(exit) http.clearRequestHeaders();
|
|
http.method = HTTP.Method.get;
|
|
http.url = url;
|
|
if (!skipToken) addAccessTokenHeader(); // HACK: requestUploadStatus
|
|
auto response = perform();
|
|
checkHttpCode(response);
|
|
return response;
|
|
}
|
|
|
|
private void del(const(char)[] url)
|
|
{
|
|
scope(exit) http.clearRequestHeaders();
|
|
http.method = HTTP.Method.del;
|
|
http.url = url;
|
|
addAccessTokenHeader();
|
|
auto response = perform();
|
|
checkHttpCode(response);
|
|
}
|
|
|
|
private void download(const(char)[] url, string filename)
|
|
{
|
|
scope(exit) http.clearRequestHeaders();
|
|
http.method = HTTP.Method.get;
|
|
http.url = url;
|
|
addAccessTokenHeader();
|
|
auto f = File(filename, "wb");
|
|
http.onReceive = (ubyte[] data) {
|
|
f.rawWrite(data);
|
|
return data.length;
|
|
};
|
|
http.perform();
|
|
checkHttpCode();
|
|
}
|
|
|
|
private auto patch(T)(const(char)[] url, const(T)[] patchData)
|
|
{
|
|
scope(exit) http.clearRequestHeaders();
|
|
http.method = HTTP.Method.patch;
|
|
http.url = url;
|
|
addAccessTokenHeader();
|
|
auto response = perform(patchData);
|
|
checkHttpCode(response);
|
|
return response;
|
|
}
|
|
|
|
private auto post(T)(const(char)[] url, const(T)[] postData)
|
|
{
|
|
scope(exit) http.clearRequestHeaders();
|
|
http.method = HTTP.Method.post;
|
|
http.url = url;
|
|
addAccessTokenHeader();
|
|
auto response = perform(postData);
|
|
checkHttpCode(response);
|
|
return response;
|
|
}
|
|
|
|
private JSONValue upload(string filepath, string url)
|
|
{
|
|
scope(exit) {
|
|
http.clearRequestHeaders();
|
|
http.onSend = null;
|
|
http.contentLength = 0;
|
|
}
|
|
http.method = HTTP.Method.put;
|
|
http.url = url;
|
|
addAccessTokenHeader();
|
|
http.addRequestHeader("Content-Type", "application/octet-stream");
|
|
auto file = File(filepath, "rb");
|
|
http.onSend = data => file.rawRead(data).length;
|
|
http.contentLength = file.size;
|
|
auto response = perform();
|
|
checkHttpCode(response);
|
|
return response;
|
|
}
|
|
|
|
private JSONValue perform(const(void)[] sendData)
|
|
{
|
|
scope(exit) {
|
|
http.onSend = null;
|
|
http.contentLength = 0;
|
|
}
|
|
if (sendData) {
|
|
http.contentLength = sendData.length;
|
|
http.onSend = (void[] buf) {
|
|
import std.algorithm: min;
|
|
size_t minLen = min(buf.length, sendData.length);
|
|
if (minLen == 0) return 0;
|
|
buf[0 .. minLen] = sendData[0 .. minLen];
|
|
sendData = sendData[minLen .. $];
|
|
return minLen;
|
|
};
|
|
} else {
|
|
http.onSend = buf => 0;
|
|
}
|
|
return perform();
|
|
}
|
|
|
|
private JSONValue perform()
|
|
{
|
|
scope(exit) http.onReceive = null;
|
|
char[] content;
|
|
http.onReceive = (ubyte[] data) {
|
|
content ~= data;
|
|
return data.length;
|
|
};
|
|
http.perform();
|
|
JSONValue json;
|
|
try {
|
|
json = content.parseJSON();
|
|
} catch (JSONException e) {
|
|
e.msg ~= "\n";
|
|
e.msg ~= content;
|
|
throw e;
|
|
}
|
|
return json;
|
|
}
|
|
|
|
private void checkHttpCode()
|
|
{
|
|
if (http.statusLine.code / 100 != 2) {
|
|
throw new OneDriveException(http.statusLine.code, http.statusLine.reason);
|
|
}
|
|
}
|
|
|
|
private void checkHttpCode(ref const JSONValue response)
|
|
{
|
|
if (http.statusLine.code / 100 != 2) {
|
|
throw new OneDriveException(http.statusLine.code, http.statusLine.reason, response);
|
|
}
|
|
}
|
|
}
|
|
|
|
unittest
|
|
{
|
|
string configDirName = expandTilde("~/.config/onedrive");
|
|
auto cfg = new config.Config(configDirName);
|
|
cfg.init();
|
|
OneDriveApi onedrive = new OneDriveApi(cfg);
|
|
onedrive.init();
|
|
std.file.write("/tmp/test", "test");
|
|
|
|
// simpleUpload
|
|
auto item = onedrive.simpleUpload("/tmp/test", "/test");
|
|
try {
|
|
item = onedrive.simpleUpload("/tmp/test", "/test");
|
|
} catch (OneDriveException e) {
|
|
assert(e.httpStatusCode == 409);
|
|
}
|
|
try {
|
|
item = onedrive.simpleUpload("/tmp/test", "/test", "123");
|
|
} catch (OneDriveException e) {
|
|
assert(e.httpStatusCode == 412);
|
|
}
|
|
item = onedrive.simpleUpload("/tmp/test", "/test", item["eTag"].str);
|
|
|
|
// deleteById
|
|
try {
|
|
onedrive.deleteById(item["id"].str, "123");
|
|
} catch (OneDriveException e) {
|
|
assert(e.httpStatusCode == 412);
|
|
}
|
|
onedrive.deleteById(item["id"].str, item["eTag"].str);
|
|
|
|
onedrive.http.shutdown();
|
|
}
|