Project

General

Profile

Site script request/donation #101 » box-fix.js

jacobk, 01/22/2022 11:19 PM

 
1
/**
2
 * Copyright 2022 Jacob K
3
 * Copyright 2022 Wojtek Kosior <koszko@koszko.org>
4
 *
5
 * This program is free software: you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation, either version 3 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
 * GNU General Public License for more details.
14
 *
15
 * As additional permission under GNU GPL version 3 section 7, you
16
 * may distribute forms of that code without the copy of the GNU
17
 * GPL normally required by section 4, provided you include this
18
 * license notice and, in case of non-source distribution, a URL
19
 * through which recipients can access the Corresponding Source.
20
 * If you modify file(s) with this exception, you may extend this
21
 * exception to your version of the file(s), but you are not
22
 * obligated to do so. If you do not wish to do so, delete this
23
 * exception statement from your version.
24
 *
25
 * As a special exception to the GPL, any HTML file which merely
26
 * makes function calls to this code, and for that purpose
27
 * includes it by reference shall be deemed a separate work for
28
 * copyright law purposes. If you modify this code, you may extend
29
 * this exception to your version of the code, but you are not
30
 * obligated to do so. If you do not wish to do so, delete this
31
 * exception statement from your version.
32
 *
33
 * You should have received a copy of the GNU General Public License
34
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
35
 *
36
 * I, Wojtek Kosior, thereby promise not to sue for violation of this file's
37
 * license. Although I request that you do not make use of this code in a
38
 * proprietary program, I am not going to enforce this in court.
39
 */
40

    
41
// meta: match should be https://***.app.box.com/s/* (*** instead of * for the first section because otherwise plain app.box.com URLs won't work)
42
// meta: some test cases (mostly found at https://old.reddit.com/search?q="box.com"&include_over_18=on&sort=new)
43
	// https://uwmadison.app.box.com/s/ydht2incbdmw1lhpjg5t40adguc0fm14
44
		// umadison's enrollment report
45
		// pdf
46
	// https://app.box.com/s/gc4ygloi4qtimeh98dq9mmydyuydawcn
47
		// password-protected 7z file (nsfw)
48
	// https://app.box.com/shared/static/su6xx6zx50cd68zdtbm3wfxhh9kwke8x.zip
49
		// a soundtrack in a zip file
50
		// This is a static download, so it works without this script.
51
	// https://app.box.com/s/vysdh2u78yih3c8leetgq82il954a3g3
52
		// some gambling ad
53
		// pptx
54
	// https://app.box.com/s/nnlplkmjhimau404qohh9my10pwmo8es
55
		// a list of books(?)
56
		// txt
57
	// https://ucla.app.box.com/s/mv32q624ojihohzh8d0mhhj0b3xluzbz
58
		// "COVID-19 Pivot Plan Decision Matrix"
59
		// If you load the proprietary scripts on this page, you'll see that there is no download button
60
	// TODO: find a public folder link (the private links I have seem to work)
61
	// TODO: find a (preferably public) link with a folder inside a folder, as these may need to be handled differently
62

    
63
/* Extract data from a script that sets multiple variables. */ // from here: https://api-demo.hachette-hydrilla.org/content/sgoogle_sheets_download/google_sheets_download.js
64

    
65
let prefetchedData = null; // This variable isn't actually used.
66
for (const script of document.scripts) {
67
    const match = /Box.prefetchedData = ({([^;]|[^}];)+})/.exec(script.textContent); // looks for "Box.prefetchedData = " in the script files and then grabs the json text after that.
68
    if (!match)
69
	continue;
70
    prefetchedData = JSON.parse(match[1]);
71
}
72

    
73
let config = null;
74
for (const script of document.scripts) {
75
    const match = /Box.config = ({([^;]|[^}];)+})/.exec(script.textContent); // looks for "Box.config = " in the script files and then grabs the json text after that.
76
    if (!match)
77
	continue;
78
    config = JSON.parse(match[1]);
79
}
80

    
81
let postStreamData = null;
82
for (const script of document.scripts) {
83
    const match = /Box.postStreamData = ({([^;]|[^}];)+})/.exec(script.textContent); // looks for "Box.postStreamData = " in the script files and then grabs the json text after that.
84
    if (!match)
85
	continue;
86
    postStreamData = JSON.parse(match[1]);
87
}
88

    
89
// empty the initial document body
90
[...document.body.childNodes].forEach(n => n.remove());
91

    
92
// create div container
93
const divContainer = document.createElement("div");
94
document.body.append(divContainer);
95

    
96
const loadingIcon = document.createElement("h1");
97
loadingIcon.innerText = "loading...";
98
loadingIcon.style.display = "none";
99

    
100
const error_msg = document.createElement("h1");
101
error_msg.innerText = "error occured :(";
102
error_msg.style.display = "none";
103

    
104
const titleHeader = document.createElement("h1");
105
titleHeader.innerText = "";
106
titleHeader.style.display = "none";
107

    
108
divContainer.append(loadingIcon, error_msg, titleHeader);
109

    
110
// get domain from URL
111
const domain = document.location.href.split("/")[2];
112

    
113
async function hack_file() {
114
    loadingIcon.style.display = "initial";
115

    
116
    const tokens_url = "/app-api/enduserapp/elements/tokens";
117
    const file_nr = postStreamData["/app-api/enduserapp/shared-item"].itemID;
118
    const file_id = `file_${file_nr}`;
119
    const shared_name = postStreamData["/app-api/enduserapp/shared-item"].sharedName;
120

    
121
    /*
122
     * We need to perform a POST to obtain a token that will be used later to
123
     * authenticate against Box's API endpoint.
124
     */
125
    const tokens_response = await fetch(tokens_url, {
126
	method: "POST",
127
	headers: {
128
	    "Accept":               "application/json",
129
	    "Content-Type":         "application/json",
130
	    "Request-Token":        config.requestToken,
131
	    "X-Box-Client-Name":    "enduserapp",
132
	    "X-Box-Client-Version": "20.712.2",
133
	    "X-Box-EndUser-API":    `sharedName=${shared_name}`,
134
	    "X-Request-Token":      config.requestToken
135
	},
136
	body: JSON.stringify({"fileIDs": [file_id]})
137
    });
138
    console.log("tokens_response", tokens_response);
139

    
140
    const access_token = (await tokens_response.json())[file_id].read;
141
    console.log("access_token", access_token);
142

    
143
    const fields = [
144
	"permissions", "shared_link", "sha1", "file_version", "name", "size",
145
	"extension", "representations", "watermark_info",
146
	"authenticated_download_url", "is_download_available"
147
    ];
148

    
149
    const file_info_url =
150
	  `https://api.box.com/2.0/files/${file_nr}?fields=${fields.join()}`;
151

    
152
    /*
153
     * We need to perform a GET to obtain file metadata. The fields we curently
154
     * make use of are "authenticated_download_url" and "file_version", but in
155
     * the request we also include names of other fields that the original Box
156
     * client would include. The metadata is then dumped as JSON on the page, so
157
     * the user, if curious, can look at it.
158
     */
159
    const file_info_response = await fetch(file_info_url, {
160
	headers: {
161
	    "Accept":            "application/json",
162
	    "Authorization":     `Bearer ${access_token}`,
163
	    "BoxApi":            `shared_link=${document.URL}`,
164
	    "X-Box-Client-Name": "ContentPreview",
165
	    "X-Rep-Hints":       "[3d][pdf][text][mp3][json][jpg?dimensions=1024x1024&paged=false][jpg?dimensions=2048x2048,png?dimensions=2048x2048][dash,mp4][filmstrip]"
166
	},
167
    });
168
    console.log("file_info_response", file_info_response);
169

    
170
    const file_info = await file_info_response.json();
171
    console.log("file_info", file_info);
172

    
173
    const params = new URLSearchParams();
174
    params.set("preview",            true);
175
    params.set("version",            file_info.file_version.id);
176
    params.set("access_token",       access_token);
177
    params.set("shared_link",        document.URL);
178
    params.set("box_client_name",    "box-content-preview");
179
    params.set("box_client_version", "2.82.0");
180
    params.set("encoding",           "gzip");
181

    
182
    /* We use file metadata from earlier requests to construct the link. */
183
    const download_url =
184
	  `${file_info.authenticated_download_url}?${params.toString()}`;
185
    console.log("download_url", download_url);
186

    
187
    const downloadButton = document.createElement("a");
188
    downloadButton.innerText = "download";
189
    downloadButton.href = download_url;
190
    downloadButton.setAttribute("style", "border-radius: 10px; padding: 20px; color: #333; background-color: lightgreen; text-decoration: none; box-shadow: -4px 8px 8px #888; display: inline-block;");
191

    
192
    const file_info_header = document.createElement("h2");
193
    file_info_header.innerText = "File info";
194

    
195
    divContainer.append(downloadButton, file_info_header,
196
			JSON.stringify(file_info));
197

    
198
	titleHeader.innerText = file_info.name;
199
	// Setting the display to "initial" here would cause the buttons to appear on the same lines as the header
200
	titleHeader.style.display = "";
201
	
202
    loadingIcon.style.display = "none";
203
}
204

    
205
function show_error() {
206
    loadingIcon.style.display = "none";
207
    error_msg.style.display = "initial";
208
}
209

    
210
if (postStreamData["/app-api/enduserapp/shared-item"].itemType == "file") {
211
    /*
212
     * We call hack_file and in case it asynchronously throws an exception, we
213
     * make an error message appear.
214
     */
215
    hack_file().then(() => {}, show_error);
216
} else if (postStreamData["/app-api/enduserapp/shared-item"].itemType == "folder") {
217
	titleHeader.innerText = postStreamData["/app-api/enduserapp/shared-folder"].currentFolderName;
218
	titleHeader.style.display = ""; // "initial" would cause the buttons to appear on the same lines as the header
219
	// TODO: implement a download folder button (included in proprietary app)
220
	/*
221
		The original download folder button sends a GET request that gets 2 URLs
222
		in the response. 1 of those URLs downloads the file, and a POST request
223
		is sent after (or maybe while in some cases?) a file is downloaded, to 
224
		let	the server know how much is downloaded.
225
	*/
226
	// for each item in the folder, show a button with a link to download it
227
	postStreamData["/app-api/enduserapp/shared-folder"].items.forEach(function(item) {
228
		console.log("item", item);
229
		const folderButton = document.createElement("a");
230
		folderButton.setAttribute("style", "border-radius: 10px; padding: 20px; color: #333; background-color: lightgreen; text-decoration: none; box-shadow: -4px 8px 8px #888; display: inline-block;"); // from https://api-demo.hachette-hydrilla.org/content/sgoogle_sheets_download/google_sheets_download.js
231
		if (item.type == "file") {
232
			folderButton.setAttribute("href", "https://"+domain+"/index.php?rm=box_download_shared_file&shared_name="+postStreamData["/app-api/enduserapp/shared-item"].sharedName+"&file_id="+item.typedID);
233
			folderButton.innerText = item.name; // show the name of the file
234
/*
235
	The above logic does not send a request_token in the body, so I'm not sure
236
	why it works, but the below (commented out) logic downloads all the files in
237
	the folder before the user even clicks on anything, due to the transparent
238
	redirects. It seems like there's no way to use fetch to get the final URL
239
	without actually following it. See:
240
	https://stackoverflow.com/questions/61458227/get-redirect-url-without-follow
241
	A way to use POST without downloading everything in advance would be
242
	to use event listeners for the buttons, but then the user could not easily
243
	see the links.
244
*/
245
/*
246
			folderButton.innerText = "loading...";
247
			fetch("https://"+domain+"/index.php?rm=box_download_shared_file&shared_name="+postStreamData["/app-api/enduserapp/shared-item"].sharedName+"&file_id="+item.typedID, {
248
				method: "POST",
249
				headers: {
250
*///					"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
251
/*					"Accept-Language":  "en-US,en;q=0.5", // TODO: find a test case in another language
252
					"Content-Type": "application/x-www-form-urlencoded",
253
					"Upgrade-Insecure-Requests": "1"
254
				},
255
				body: "request_token="+config.requestToken
256
			}).then(function(response) {
257
				console.log(response);
258
				folderButton.setAttribute("href", response.url);
259
				folderButton.innerText = item.name;
260
			});
261
*/
262
		} else if (item.type == "folder") {
263
			folderButton.innerText = "[folders inside folders not yet supported]";
264
		} else {
265
			folderButton.innerText = "[this item type is not supported]";
266
		}
267
		divContainer.appendChild(folderButton);
268
	})
269
} else {
270
	console.log('expected "folder" or "file" as the item type (postStreamData["/app-api/enduserapp/shared-item"].itemType) but got ' + postStreamData["/app-api/enduserapp/shared-item"].itemType + ' instead; this item type is not implemented');
271
	show_error();
272
}
(3-3/3)