slack-gc 1.0.1

A barebones utility for cleaning up old slack file uploads
//! Slack Garbage Collector
//! =======================
//! See README.md for the full details. Basically just a little utility
//! to scrape the slack API for a list of all uploaded files older than
//! a certain age and issue deletion reuqests for each one.
//!
//! Usage is fairly simple -- compile it and run it with one or more users'
//! tokens as arguments on the command line. Nothing will be deleted with the
//! default arguments, so feel free to play around until you're ready to add
//! the --delete flag.

// Crate-wide Compiler Configuration
// ---------------------------------
// Rust expresses this stuff as crate annotations, rather than in the build
// tool, so we have to apply them at the root of the crate (i.e. main).
#![deny(
    missing_docs,
    missing_debug_implementations,
    missing_copy_implementations,
    trivial_casts,
    trivial_numeric_casts,
    unused_import_braces,
    unused_qualifications
)]
#![cfg_attr(
    feature = "cargo-clippy",
    warn(
        clippy,
        clippy_correctness,
        clippy_style,
        clippy_complexity,
        clippy_perf,
        clippy_cargo,
        float_cmp_const,
        if_not_else,
        similar_names,
        shadow_unrelated,
        stutter,
        unicode_not_nfc,
        unimplemented,
        unnecessary_unwrap,
        unseparated_literal_suffix,
        use_self,
        used_underscore_binding,
        wrong_pub_self_convention,
        decimal_literal_representation,
        empty_enum,
        enum_glob_use,
        expl_impl_clone_on_copy,
        fallible_impl_from,
        invalid_upcast_comparisons,
        mem_forget,
        multiple_crate_versions,
        multiple_inherent_impl,
        mut_mut,
        mutex_integer,
        needless_borrow,
        needless_continue,
        option_unwrap_used,
        print_stdout,
        pub_enum_variant_names,
        range_plus_one,
        replace_consts
    )
)]
#![cfg_attr(
    feature = "cargo-clippy",
    allow(redundant_field_names, multiple_crate_versions)
)]

#[macro_use]
extern crate log;
#[macro_use]
extern crate structopt;

extern crate chrono;
extern crate failure;
extern crate fern;
extern crate pretty_bytes;
extern crate rayon;
extern crate slack_api;

use pretty_bytes::converter::convert as prettify_bytes;
use rayon::prelude::*;
use std::collections::HashMap;

mod config;

static BATCH_SIZE: u32 = 1000;

fn calculate_rention_timestamp(days: u32) -> u32 {
    let now = chrono::Local::now();
    let retention_max = now - chrono::Duration::days(days as i64);
    retention_max.timestamp() as u32
}

fn list_files(token: &str, retention_days: u32) -> Vec<slack_api::File> {
    let mut done = false;
    let mut page: u32 = 1;
    let mut expected_pages: u32 = 1;
    let mut files = vec![];
    let client = slack_api::requests::default_client().unwrap();
    debug!("++ requesting file listings for a user");
    while !done {
        let request = slack_api::files::ListRequest {
            ts_to: Some(calculate_rention_timestamp(retention_days)),
            count: Some(BATCH_SIZE),
            page: Some(page),
            ..Default::default()
        };
        done = true;
        let resp = slack_api::files::list(&client, &token, &request)
            .expect("The slack client failed to yield a response object");

        if let Some(new_files) = resp.files {
            files.extend(new_files);
        }

        if let Some(paging) = resp.paging {
            if let (Some(cur_page), Some(pages)) = (paging.page, paging.pages) {
                info!(" * received page {}/{}", cur_page, expected_pages);
                expected_pages = pages as u32;
                if cur_page < pages {
                    done = false;
                    page = 1 + cur_page as u32;
                }
            }
        }
    }
    debug!("-- finished pulling listings for a user");
    files
}

fn delete_file(token: &str, file_id: &str) -> bool {
    let client = slack_api::requests::default_client().unwrap();
    let del_request = slack_api::files::DeleteRequest {
        file: &file_id,
        ..Default::default()
    };
    match slack_api::files::delete(&client, &token, &del_request) {
        Ok(_) => true,
        Err(err) => {
            error!("del({}) failed: {}", file_id, err);
            false
        },
    }
}

fn main() -> Result<(), failure::Error> {
    let options = config::parse_args();

    let level = if options.verbose {
        if options.quiet {
            log::LevelFilter::Warn
        } else {
            log::LevelFilter::Debug
        }
    } else {
        log::LevelFilter::Info
    };

    config::setup_logger(level)?;
    info!("Starting up and querying Slack!");
    info!(
        "We're looking for files published more than {} day(s) ago.",
        options.retention_days
    );
    info!(
        "If we find any, we {}.",
        if options.issue_deletions {
            "will purge them"
        } else {
            "will tell you about them"
        }
    );

    // Request file listings for all the tokens provided on the CLI
    let file_sets: HashMap<_, _> = options
        .token
        .par_iter()
        .map(|token| (token, list_files(token, options.retention_days)))
        .collect();

    let (num_files, total_bytes) = file_sets
        .iter()
        .map(|(_, files)| {
            (
                files.len(),
                files
                    .iter()
                    .fold(0, |tally, file| tally + file.size.unwrap_or(0)),
            )
        })
        .fold((0, 0), |(acc_num, acc_bytes), (num, bytes)| {
            (acc_num + num, acc_bytes + bytes)
        });
    if num_files == 0 {
        info!("No files need to be deleted for any users.");
        return Ok(());
    }
    info!(
        "Identified {} files for deletion, totalling {}.",
        num_files,
        prettify_bytes(total_bytes as f64)
    );

    if options.issue_deletions {
        let delete_results: Vec<_> = file_sets
            .par_iter()
            .map(|(token, files)| -> Vec<&slack_api::File> {
                if files.len() == 0 {
                    info!("No files need to be deleted for this user.");
                    return vec![];
                }
                files.clone().sort_by(|a, b| {
                    b.size.unwrap_or(0).cmp(&a.size.unwrap_or(0))
                });
                let files_deleted: Vec<_> = files
                    .par_iter()
                    .filter_map(|f| {
                        if let Some(ref id) = f.id {
                            Some((f, delete_file(token, &id)))
                        } else {
                            None
                        }
                    })
                    .filter_map(
                        |(f, success)| {
                            if success {
                                Some(f)
                            } else {
                                None
                            }
                        },
                    )
                    .collect();
                files_deleted
            })
            .flatten()
            .collect();
        info!(
            "Deleted {} files totalling {}",
            delete_results.len(),
            prettify_bytes(
                delete_results
                    .iter()
                    .fold(0, |tally, file| tally + file.size.unwrap_or(0))
                    as f64
            )
        )
    } else {
        warn!(
            "Not actually deleting anything (did you mean to pass --delete?)"
        );
    }

    Ok(())
}