Browse Source

Initial skeleton

pull/1/head
Toromino 1 year ago
commit
9273f8f535
6 changed files with 1555 additions and 0 deletions
  1. 2
    0
      .gitignore
  2. 1226
    0
      Cargo.lock
  3. 13
    0
      Cargo.toml
  4. 7
    0
      LICENSE
  5. 3
    0
      README.md
  6. 304
    0
      src/main.rs

+ 2
- 0
.gitignore View File

@@ -0,0 +1,2 @@
1
+/target
2
+**/*.rs.bk

+ 1226
- 0
Cargo.lock
File diff suppressed because it is too large
View File


+ 13
- 0
Cargo.toml View File

@@ -0,0 +1,13 @@
1
+[package]
2
+name = "pubcleaner"
3
+version = "0.1.0"
4
+authors = ["Toromino <foxhkron@toromino.de>"]
5
+edition = "2018"
6
+
7
+[dependencies]
8
+chrono = { version = "0.4", features = ["serde"] }
9
+getopts = "0.2.21"
10
+reqwest = { version = "0.10.1", features = ["json"] }
11
+serde = { version = "1.0", features = ["derive"] }
12
+serde_json = "1.0"
13
+tokio = { version = "0.2", features = ["full"] }

+ 7
- 0
LICENSE View File

@@ -0,0 +1,7 @@
1
+Copyright 2020 Dennis Buchholz
2
+
3
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 3
- 0
README.md View File

@@ -0,0 +1,3 @@
1
+# Pubcleaner
2
+
3
+

+ 304
- 0
src/main.rs View File

@@ -0,0 +1,304 @@
1
+extern crate chrono;
2
+extern crate getopts;
3
+extern crate reqwest;
4
+extern crate serde_json;
5
+
6
+use chrono::prelude::*;
7
+use getopts::Options;
8
+use serde::{Deserialize, Serialize};
9
+use std::fs::File;
10
+use std::io;
11
+use std::io::{Read, Write};
12
+use tokio;
13
+
14
+#[derive(Serialize, Deserialize)]
15
+struct Account {
16
+    id: String,
17
+}
18
+
19
+#[derive(Serialize, Deserialize)]
20
+struct Credentials {
21
+    client_id: String,
22
+    client_secret: String,
23
+}
24
+
25
+impl Credentials {
26
+    async fn from_url(client: &reqwest::Client, url: &str) -> Result<Self, String> {
27
+        println!("Trying to obtain client credentials ...");
28
+        let params = [
29
+            ("client_name", "Pubcleaner"),
30
+            ("redirect_uris", "urn:ietf:wg:oauth:2.0:oob"),
31
+            ("scopes", "read read:accounts write"),
32
+        ];
33
+        let response = client
34
+            .post(&format!("https://{}/api/v1/apps", url))
35
+            .form(&params)
36
+            .send()
37
+            .await;
38
+        match response {
39
+            Ok(value) => value
40
+                .json::<Credentials>()
41
+                .await
42
+                .or_else(|_| Err("Could not decode JSON body from response".to_string())),
43
+            Err(_) => Err(format!("Could not connect to '{}'", url)),
44
+        }
45
+    }
46
+}
47
+
48
+#[derive(Serialize, Deserialize)]
49
+struct Status {
50
+    id: String,
51
+    created_at: String,
52
+}
53
+
54
+impl Status {
55
+    async fn delete(&self, client: &reqwest::Client, url: &str, token: &str) {
56
+        client
57
+            .delete(&format!("https://{}/api/v1/statuses/{}", url, &self.id))
58
+            .header("Authorization", &format!("Bearer {}", &token))
59
+            .send()
60
+            .await
61
+            .expect("An error occured while deleting a status");
62
+        println!("Successfully deleted status '{}'", &self.id);
63
+    }
64
+}
65
+
66
+#[derive(Serialize, Deserialize)]
67
+struct Token {
68
+    access_token: String,
69
+}
70
+
71
+impl Token {
72
+    async fn from_url(
73
+        client: &reqwest::Client,
74
+        url: &str,
75
+        authorization_code: &str,
76
+        credentials: &Credentials,
77
+    ) -> Result<Self, reqwest::Error> {
78
+        let params = [
79
+            ("scopes", "read read:accounts write"),
80
+            ("client_id", &credentials.client_id),
81
+            ("client_secret", &credentials.client_secret),
82
+            ("code", &authorization_code),
83
+            ("grant_type", "authorization_code"),
84
+        ];
85
+        let response = client
86
+            .post(&format!("https://{}/oauth/token", url))
87
+            .form(&params)
88
+            .send()
89
+            .await;
90
+
91
+        response
92
+            .unwrap_or_else(|_| panic!("Could not connect to host!"))
93
+            .json::<Token>()
94
+            .await
95
+    }
96
+}
97
+
98
+async fn fetch_statuses(
99
+    client: &reqwest::Client,
100
+    url: &str,
101
+    id: &str,
102
+    token: &str,
103
+    max: Option<String>,
104
+) -> Vec<Status> {
105
+    let max_id = match max {
106
+        Some(id) => format!("&max_id={}", id),
107
+        None => String::from(""),
108
+    };
109
+
110
+    let response = client
111
+        .get(&format!(
112
+            "https://{}/api/v1/accounts/{}/statuses?limit=50{}",
113
+            url, id, max_id
114
+        ))
115
+        .header("Authorization", &format!("Bearer {}", &token))
116
+        .send()
117
+        .await;
118
+
119
+    response
120
+        .unwrap_or_else(|_| panic!("Could not fetch statuses!"))
121
+        .json::<Vec<Status>>()
122
+        .await
123
+        .unwrap()
124
+}
125
+
126
+async fn load_storage() -> Option<serde_json::Value> {
127
+    let mut buffer = String::new();
128
+    let file = File::open("storage.json");
129
+
130
+    match file {
131
+        Ok(mut f) => {
132
+            f.read_to_string(&mut buffer)
133
+                .expect("Could not read 'storage.json'");
134
+            serde_json::from_str(&buffer).ok()
135
+        }
136
+        Err(_) => None,
137
+    }
138
+}
139
+
140
+async fn verify_credentials(client: &reqwest::Client, url: &str, token: &str) -> Option<Account> {
141
+    let response = client
142
+        .get(&format!(
143
+            "https://{}/api/v1/accounts/verify_credentials",
144
+            url
145
+        ))
146
+        .header("Authorization", &format!("Bearer {}", &token))
147
+        .send()
148
+        .await;
149
+
150
+    response
151
+        .unwrap_or_else(|_| panic!("Could not connect to host!"))
152
+        .json::<Account>()
153
+        .await
154
+        .ok()
155
+}
156
+
157
+fn print_usage(program: &str, opts: Options) {
158
+    let brief = format!("Usage: {} [options]", program);
159
+    print!("{}", opts.usage(&brief));
160
+}
161
+
162
+fn prompt() -> String {
163
+    let mut input = String::new();
164
+    io::stdin().read_line(&mut input).unwrap();
165
+    input.trim_end().parse::<String>().unwrap()
166
+}
167
+
168
+#[tokio::main]
169
+async fn main() {
170
+    let args: Vec<String> = std::env::args().collect();
171
+    let client = reqwest::Client::new();
172
+    let storage: Option<serde_json::Value> = load_storage().await;
173
+
174
+    if storage.is_some() {
175
+        println!("Using credentials from 'storage.json'");
176
+    }
177
+
178
+    let mut options = Options::new();
179
+    options.optflag("h", "help", "print this help menu");
180
+    options.optopt(
181
+        "a",
182
+        "auth_code",
183
+        "set an authorization code with read+write permissions",
184
+        "abc123cba321",
185
+    );
186
+    options.optopt(
187
+        "t",
188
+        "token",
189
+        "set an access token with read+write permissions",
190
+        "abc123cba321",
191
+    );
192
+    options.optopt("u", "url", "set an instance/node url", "example.tld");
193
+
194
+    let matches = match options.parse(&args[1..]) {
195
+        Ok(m) => m,
196
+        Err(e) => panic!(e.to_string()),
197
+    };
198
+
199
+    if matches.opt_present("h") {
200
+        print_usage(&args[0], options);
201
+        return;
202
+    }
203
+
204
+    let url: &str = &matches.opt_str("url").unwrap_or_else(|| match &storage {
205
+        Some(value) => value["url"].as_str().unwrap().to_string(),
206
+        None => {
207
+            println!("Enter the URL of your instance/node: (example.tld)");
208
+            prompt()
209
+        }
210
+    });
211
+
212
+    let credentials: Credentials = match &storage {
213
+        Some(value) => serde_json::from_value(value["credentials"].clone())
214
+            .unwrap_or_else(|_| panic!("Could not read credentials from storage.json!")),
215
+        None => match Credentials::from_url(&client, url).await {
216
+            Ok(value) => value,
217
+            Err(e) => panic!(e),
218
+        },
219
+    };
220
+
221
+    // Unless an authorization code was specified, the code will be loaded from storage.json.
222
+    // If storage.json does not exist, the user will be prompted to generate a new authorization code.
223
+    let authorization_code = &matches
224
+        .opt_str("auth_code")
225
+        .unwrap_or_else(|| {
226
+    match &storage {
227
+            Some(value) => value["authorization_code"].as_str().unwrap().to_string(),
228
+            None => {
229
+        let oauth_url = format!("https://{}/oauth/authorize?response_type=code&redirect_uri=urn:ietf:wg:oauth:2.0:oob&client_id={}&scope=read%20write%20read:accounts",
230
+        url, credentials.client_id);
231
+        println!("Please visit {}, log-in with your account and copy the authorization code.", oauth_url);
232
+        println!("Enter the authorization code:");
233
+        prompt()
234
+        }}
235
+    });
236
+
237
+    let access_token: String = match matches.opt_str("token") {
238
+        Some(value) => value,
239
+        None => match &storage {
240
+            Some(value) => value["access_token"].as_str().unwrap().to_string(),
241
+            None => {
242
+                println!("Attempting to obtain a token ...");
243
+
244
+                Token::from_url(&client, &url, &authorization_code, &credentials)
245
+                    .await
246
+                    .unwrap()
247
+                    .access_token
248
+            }
249
+        },
250
+    };
251
+
252
+    let account_id = match verify_credentials(&client, &url, &access_token).await {
253
+        Some(account) => account.id,
254
+        None => panic!("Access token is invalid!"),
255
+    };
256
+    println!("Successfully verified access token!");
257
+
258
+    // If no storage.json file exists, create it and save the access token and the credentials.
259
+    if storage.is_none() {
260
+        println!("Saving new credentials to 'storage.json'");
261
+        let mut file = File::create("storage.json").unwrap();
262
+        file.write_all(serde_json::json!({"access_token": &access_token, "authorization_code": &authorization_code, "url": &url, "credentials": &credentials}).to_string().as_bytes()
263
+        ).expect("Could not write to 'storage.json'");
264
+    }
265
+
266
+    println!("\n");
267
+    println!("Specify the date of the statuses you want to keep. Leave this empty if you want to delete everything!");
268
+    println!("Example: '8 weeks' (will delete all statuses older than 8 weeks)");
269
+    println!(
270
+        "      or '2020-01-11' (will delete all statuses created before the 11th of January, 2020)"
271
+    );
272
+    println!(
273
+        "      or '2016-03-16T14:34:26.392Z' (will delete all statuses created before the 16th of March, 2016)"
274
+    );
275
+
276
+    let timestamp = prompt().parse::<NaiveDate>().unwrap();
277
+    let mut counter: i32 = 0;
278
+    let mut last_largest_id: Option<String> = None;
279
+    loop {
280
+        let mut fetched_statuses =
281
+            fetch_statuses(&client, &url, &account_id, &access_token, last_largest_id).await;
282
+        fetched_statuses.sort_by(|a, b| a.created_at.cmp(&b.created_at));
283
+        for status in &fetched_statuses {
284
+            if status
285
+                .created_at
286
+                .parse::<DateTime<Utc>>()
287
+                .unwrap()
288
+                .timestamp()
289
+                < NaiveDateTime::new(timestamp, NaiveTime::from_hms(0, 0, 0)).timestamp()
290
+            {
291
+                status.delete(&client, &url, &access_token).await;
292
+                counter += 1;
293
+            }
294
+        }
295
+
296
+        if fetched_statuses.is_empty() && counter > 1 {
297
+            break;
298
+        } else {
299
+            last_largest_id = Some(fetched_statuses[0].id.clone());
300
+        }
301
+    }
302
+
303
+    println!("Pubcleaner finished deleting {} statuses!", &counter);
304
+}

Loading…
Cancel
Save