diff --git a/.gitignore b/.gitignore index 3e1d2a8..4765ea9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ build/ setup.py filebrowse.py +/praw.ini +**/venv +**/.idea \ No newline at end of file diff --git a/README.md b/README.md index 1d817b9..5badb02 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -#RedditStorage -######Cloud storage that uses Reddit as a backend. +# RedditStorage +###### Cloud storage that uses Reddit as a backend. ============= @@ -7,26 +7,69 @@ RedditStorage is an application that allows you to store on reddit subreddits vi ============= -Requirements: -* reddit account (preferably with over 1 link karma on it) -* private subreddit with your reddit account as a moderator (make sure to set the spam filter strength of self posts and comments to "low") -* praw 2.1.21 -* Python 2.7 -* pycrypto 2.6.1 -* wxPython 3.0+ +## Requirements: +* A Reddit account (preferably with over 1 link karma on it) +* A private subreddit with your reddit account as a moderator (make sure to set the spam filter strength of self posts and comments to "low") +* praw 7.7.0 +* Python 3.5+ (and Pip3) +* pycryptodome 3.17 +* wxPython 3.0 +* Pypubsub 4.3.0 + +### Required Files +You'll need a few things first: +1. A config file named `praw.ini` to be used with `configparser`. See an example here of what the format should look like: [example_praw.ini](/example_praw.ini) +2. Fill out `redditglobals.py` with the label you're using in the `praw.ini` file. + 1. Replace `reddit storage bot` with whatever label you set in between the square brackets in your `praw.ini` + +### Python installation: + +#### All Operating Systems: + +Download the latest version of Python from [here](https://www.python.org/downloads/). Pip is included by default. + +#### Linux only + +If you can't use a browser for whatever reason, run this instead: + +```shell +sudo apt-get install python3 python3-pip +``` + +### Package installation: + +```shell +pip install praw pycryptodome wxpython pypubsub +``` + ============= -How to Use: +## Usage: + +### Start-up: +```shell +python main.py +``` + +### Posting files -1. RedditStorage uses an AES encryption algorithm which requires you to choose a password(e.g. "bunny"). -2. Run: `python RedditStorage.py` -3. Enter your username, password, subreddit and desired encryption key -4. Choose the file you want to upload -5. When getting the file, choose the file you want to get and how/where you want to save it +1. Enter the encryption key to be used to encrypt the files. Treat this like a normal password. *If you lose this, we can't help you decrypt it* +2. Choose the file you want to upload. +3. Press `Post`. +*The window may say "Not Responding" or freeze if you choose large files. This is normal and you need to wait it out.* -Screenshots +### Downloading files + +1. Enter the name of the file to get. *It takes some time for Reddit's search index to update (about every 20 minutes). You should check that you can find it using Reddit's search feature first before running this.* +2. Enter the encryption key you used to encrypt the file when posting it. +3. Click `Save File As` and select a location and name to save the file as. Alternatively, enter the save location manually. +4. Press `Get`. + +*As before, the window may say "Not Responding" or you may get the beachball of death on MacOS. Again, just wait it out.* + +### Screenshots =========== @@ -37,11 +80,9 @@ Screenshots ![ss4](screenshot4.png "README.md uploaded") ![ss5](screenshot5.png "Big file made up of linked comments") - -To Do +## To Do ============== -* Save username/password between sessions * Upload as webapp -* Auto generate subreddits +* Auto generate subreddits \ No newline at end of file diff --git a/RedditStorage.py b/RedditStorage.py index d58c030..a394161 100644 --- a/RedditStorage.py +++ b/RedditStorage.py @@ -1,300 +1,294 @@ #!/usr/bin/python -# simple.py +from typing import Union -import praw from reddit import * -import getpass from crypt import AESCipher -import hashlib -import os -import base64 -from Crypto.Cipher import AES -from Crypto import Random - -from redditglobals import * +from redditglobals import * import wx -from wx.lib.pubsub import pub +from pubsub import pub -# cleanup dist and build directory first (for new py2exe version) -#if os.path.exists("dist/prog"): - # shutil.rmtree("dist/prog") +from threading import Thread, Lock, Event +from time import sleep -#if os.path.exists("dist/lib"): - # shutil.rmtree("dist/lib") +wildcard = "All files (*.*)|*.*" -#if os.path.exists("build"): - # shutil.rmtree("build") -wildcard = "All files (*.*)|*.*" - +# noinspection PyAttributeOutsideInit class RedditList(wx.Frame): - + """ + A window showing the list of files posted to Reddit. Listens to the subredditListener pub. + """ def __init__(self): - wx.Frame.__init__(self,None,wx.ID_ANY,"Files Stored", size = (300, 400)) + wx.Frame.__init__(self, None, wx.ID_ANY, "Files Stored", size=(300, 400)) self.panel = wx.Panel(self) - pub.subscribe(self.subredditListener, "subredditListener") - self.InitUI() - - def InitUI(self): + pub.subscribe(self.subreddit_listener, "subredditListener") + self._init_UI() - self.fileList = wx.ListCtrl(self.panel, size=(-1,300), style = wx.LC_REPORT - |wx.EXPAND) - self.fileList.InsertColumn(0,"File Name", width = 500) + def _init_UI(self): + """ + Initializes the UI of the window + """ + self.fileList = wx.ListCtrl(self.panel, size=(-1, 300), style=wx.LC_REPORT + | wx.EXPAND) + self.fileList.InsertColumn(0, "File Name", width=500) - self.gs = wx.GridSizer(1,2,0,0) + self.gs = wx.GridSizer(1, 2, 0, 0) - submitButton = wx.Button(self.panel, label = "Submit") - submitButton.Bind(wx.EVT_BUTTON, self.onSubmit) - closeButton = wx.Button(self.panel, label= "Close") - closeButton.Bind(wx.EVT_BUTTON, self.onClose) + submit_button = wx.Button(self.panel, label="Submit") + submit_button.Bind(wx.EVT_BUTTON, self.onSubmit) + close_button = wx.Button(self.panel, label="Close") + close_button.Bind(wx.EVT_BUTTON, self.onClose) - saveButton = wx.Button(self.panel, label="Save Fields") + save_button = wx.Button(self.panel, label="Save Fields") - generateButton = wx.Button(self.panel, label = "Generate Subreddit") + generate_button = wx.Button(self.panel, label="Generate Subreddit") - self.gs.Add(submitButton) - self.gs.Add(closeButton) + self.gs.Add(submit_button) + self.gs.Add(close_button) sizer = wx.BoxSizer(wx.VERTICAL) - flags = wx.ALL|wx.EXPAND - sizer.Add(self.fileList, 0, wx.ALL|wx.EXPAND, 5) - - sizer.Add(self.gs, flag=wx.ALIGN_BOTTOM|wx.EXPAND|wx.ALL, border=5) + flags = wx.ALL | wx.EXPAND + sizer.Add(self.fileList, 0, wx.ALL | wx.EXPAND, 5) + + sizer.Add(self.gs, flag=wx.EXPAND | wx.ALL, border=5) self.panel.SetSizer(sizer) - def subredditListener(self, subredditName, username, password): - r = praw.Reddit("reddit storage bot " + username) - r.login(username, password) - subreddit = r.get_subreddit(subredditName) + # Used by onClickGetRedditList + def subreddit_listener(self, subreddit_name: str): + """ + Subscribed to subredditListener event. Fetches posts on the subreddit + :param subreddit_name: + """ + print("Fetching posts...\n") + sub = REDDIT.subreddit(subreddit_name) global posts - posts = subreddit.get_new(limit=1000) - index = 0 - self.myRowDict = {} #dictionary used for retrival later + posts = sub.new(limit=1000) + self.myRowDict = {} # dictionary used for retrival later for x in posts: - print str(x) - self.fileList.InsertStringItem(index, x.title) + print(str(x)) + index = self.fileList.InsertItem(x.title) self.myRowDict[index] = x.title - index+=1 - print "done" - - + print("Done fetching list of posts\n") + # Closes window def onClose(self, event): self.Close() + # When user presses submit button in the new window def onSubmit(self, event): - pub.sendMessage("fileListener", fileName = self.myRowDict[get_selected_items(self.fileList)[0]]) + pub.sendMessage("fileListener", fileName=self.myRowDict[get_selected_items(self.fileList)[0]]) self.Close() -def get_selected_items(list_control): - - #Gets the selected items for the list control. - #Selection is returned as a list of selected indices, - #low to high. - +def get_selected_items(list_control: wx.ListCtrl): + """ + Gets the selected items for the list control. + Selection is returned as a list of selected indices, + low to high. + :param list_control: The list control to retrieve all selected items from + """ + selection = [] - # start at -1 to get the first selected item + # start at index -1 to get the first selected item current = -1 while True: - next = GetNextSelected(list_control, current) - if next == -1: + selectedItem = GetNextSelected(list_control, current) + if selectedItem == -1: return selection - selection.append(next) - current = next + selection.append(selectedItem) + current = selectedItem -def GetNextSelected(list_control, current): - #Returns next selected item, or -1 when no more - return list_control.GetNextItem(current, - wx.LIST_NEXT_ALL, - wx.LIST_STATE_SELECTED) +def GetNextSelected(list_control: wx.ListCtrl, current_index: int): + """ + Retrieves the next selected item in the list control + :param list_control: The list control to retrieve an item from + :param current_index: Current index to retrieve on. -1 retrieves the first item + :return: The first selected item after current_index, or -1 if no item is found + """ + + return list_control.GetNextItem(current_index, + wx.LIST_NEXT_ALL, + wx.LIST_STATE_SELECTED) +# noinspection PyPep8Naming,PyRedundantParentheses class PostPanel(wx.Panel): + """ + This is the panel where files can be posted. + The window switches to this panel when pressing the "Post" tab. + """ - def __init__(self,parent): - wx.Panel.__init__(self,parent=parent,id=wx.ID_ANY) - + def __init__(self, parent: Union[wx.Window, None] = None): + """ + Initializes the 'Post' panel + :param parent: The parent process of the panel; optional + """ + wx.Panel.__init__(self, parent=parent, id=wx.ID_ANY) self.InitUI() + # noinspection PyAttributeOutsideInit def InitUI(self): - ID_POST_BUTTON = wx.NewId() - ID_BROWSE_FILE_BUTTON = wx.NewId() + """ + Initializes the panel elements + """ + ID_POST_BUTTON = wx.NewIdRef(count=1) + ID_BROWSE_FILE_BUTTON = wx.NewIdRef(count=1) - hbox = wx.BoxSizer(wx.VERTICAL) + hit_box = wx.BoxSizer(wx.VERTICAL) - fgs = wx.FlexGridSizer(5,2,9,15) - gs = wx.GridSizer(2,2,9,10) + fgs = wx.FlexGridSizer(5, 2, 9, 15) + gs = wx.GridSizer(2, 2, 9, 10) - global username - username = wx.StaticText(self, label="Username") - password = wx.StaticText(self, label="Password") - subreddit = wx.StaticText(self, label="Subreddit") - KEYPASS = wx.StaticText(self, label = "Encryption key") + KEY_PASS = wx.StaticText(self, label="Encryption key") filename = wx.StaticText(self, label="Filepath") - post = wx.Button(self, ID_POST_BUTTON, "Post") - browseFile=wx.Button(self, ID_BROWSE_FILE_BUTTON, "Browse File") + post = wx.Button(self, ID_POST_BUTTON, "Post") + browseFile = wx.Button(self, ID_BROWSE_FILE_BUTTON, "Browse File") + global postMessage - postMessage = wx.StaticText(self,label = "") + """Refers to the message to say posting is done.""" + postMessage = wx.StaticText(self, label="") - self.usernameField = wx.TextCtrl(self) - self.passwordField = wx.TextCtrl(self, style = wx.TE_PASSWORD) - self.subredditField = wx.TextCtrl(self) - self.keypassField = wx.TextCtrl(self) + self.passwordField = wx.TextCtrl(self) self.filepathField = wx.TextCtrl(self) - - fgs.AddMany([(username), (self.usernameField, 1, wx.EXPAND), (password), - (self.passwordField, 1, wx.EXPAND), (subreddit, 1, wx.EXPAND), (self.subredditField, 1, wx.EXPAND), - (KEYPASS, 1, wx.EXPAND), (self.keypassField, 1, wx.EXPAND), (filename, 1, wx.EXPAND), - (self.filepathField, 1, wx.EXPAND)]) + fgs.AddMany([(KEY_PASS, 1, wx.EXPAND), (self.passwordField, 1, wx.EXPAND), (filename, 1, wx.EXPAND), + (self.filepathField, 1, wx.EXPAND)]) - gs.AddMany([(post,1,wx.EXPAND),(browseFile,1,wx.EXPAND), (postMessage)]) + gs.AddMany([(post, 1, wx.EXPAND), (browseFile, 1, wx.EXPAND), (postMessage)]) - fgs.AddGrowableCol(1, 1) - hbox.Add(fgs, proportion=1, flag=wx.ALL|wx.EXPAND, border=15) - hbox.Add(gs, proportion=1, flag=wx.ALL|wx.EXPAND, border=15) - #hbox.Add(postMessage, proportion=1, flag=wx.ALL|wx.EXPAND, border=15) - self.SetSizer(hbox) + hit_box.Add(fgs, proportion=1, flag=wx.ALL | wx.EXPAND, border=15) + hit_box.Add(gs, proportion=1, flag=wx.ALL | wx.EXPAND, border=15) + self.SetSizer(hit_box) post.Bind(wx.EVT_BUTTON, self.onClickPostItem, post) browseFile.Bind(wx.EVT_BUTTON, self.onClickBrowseFile, browseFile) - - def onClickBrowseFile(self,e): - # Create the dialog. In this case the current directory is forced as the starting - # directory for the dialog, and no default file name is forced. This can easilly - # be changed in your program. This is an 'open' dialog, and allows multitple - # file selections as well. - # - # Finally, if the directory is changed in the process of getting files, this - # dialog is set up to change the current working directory to the path chosen. + + def onClickBrowseFile(self, e): + """Create the dialog. In this case the current directory is forced as the starting + directory for the dialog, and no default file name is forced. This can easily + be changed in your program. This is an 'open' dialog, and allows multiple + file selections as well. + + Finally, if the directory is changed in the process of getting files, this + dialog is set up to change the current working directory to the path chosen. + """ dlg = wx.FileDialog( self, message="Choose a file", - defaultDir=os.getcwd(), + defaultDir=os.getcwd(), defaultFile="", wildcard=wildcard, - style=wx.OPEN | wx.MULTIPLE | wx.CHANGE_DIR - ) + style=wx.FD_OPEN | wx.FD_MULTIPLE | wx.FD_CHANGE_DIR + ) # Show the dialog and retrieve the user response. If it is the OK response, # process the data. if dlg.ShowModal() == wx.ID_OK: - #Use GetPaths() for multiple files - paths = dlg.GetPath() - self.filepathField.SetValue(paths) - - #self.log.WriteText('You selected %d files:' % len(paths)) + # Use GetPaths() for multiple files + paths = dlg.GetPaths() # Todo: allow user to select and post multiple files + self.filepathField.SetValue(paths[0]) - #for path in paths: - # self.log.WriteText(' %s\n' % path) - - # Compare this with the debug above; did we change working dirs? - #self.log.WriteText("CWD: %s\n" % os.getcwd()) - - # Destroy the dialog. Don't do this until you are done with it! - # BAD things can happen otherwise! dlg.Destroy() - - def onClickPostItem(self,e): - if (self.usernameField.IsEmpty()): - postMessage.SetLabel("No Username Specified") - elif (self.passwordField.IsEmpty()): - postMessage.SetLabel("No Password Entered") - elif (self.subredditField.IsEmpty()): - postMessage.SetLabel("No Subreddit Specified") - elif (self.keypassField.IsEmpty()): + def onClickPostItem(self, e): + """Executes when user clicks the 'Post' button""" + if (self.passwordField.IsEmpty()): postMessage.SetLabel("No Encryption Key Specified") elif (self.filepathField.IsEmpty()): postMessage.SetLabel("No Filepath Specified") else: - postItem(self.usernameField.GetValue(), self.passwordField.GetValue(), self.subredditField.GetValue(), - self.filepathField.GetValue(), self.keypassField.GetValue()) + postItem( # self.usernameField.GetValue(), self.passwordField.GetValue(), self.subredditField.GetValue(), + self.filepathField.GetValue(), self.passwordField.GetValue()) + +# noinspection PyPep8Naming,PyRedundantParentheses class GetPanel(wx.Panel): + """ + This is the panel where files can be retrieved. + The window switches to this panel when pressing the "Get" tab. + """ - def __init__(self,parent): - wx.Panel.__init__(self,parent=parent,id=wx.ID_ANY) + def __init__(self, parent: Union[wx.Window, None] = None): + """ + Initializes the 'Get' panel + :param parent: The parent process of the panel + """ + wx.Panel.__init__(self, parent=parent, id=wx.ID_ANY) pub.subscribe(self.fileListener, "fileListener") self.InitUI() def fileListener(self, fileName): + """Sets the value in the 'Filepath' field. + Subscribed to 'fileListener' event""" self.fileToGetField.SetValue(fileName) + # noinspection PyAttributeOutsideInit def InitUI(self): - ID_GET_BUTTON = wx.NewId() - ID_SAVE_FILE_BUTTON = wx.NewId() - ID_GET_REDDIT_LIST_BUTTON = wx.NewId() + """Initializes the UI for the panel""" + ID_GET_BUTTON = wx.NewIdRef(count=1) + ID_SAVE_FILE_BUTTON = wx.NewIdRef(count=1) + ID_GET_REDDIT_LIST_BUTTON = wx.NewIdRef(count=1) hbox = wx.BoxSizer(wx.VERTICAL) - fgs = wx.FlexGridSizer(6,2,9,15) - gs = wx.GridSizer(2,2,9,10) - + fgs = wx.FlexGridSizer(6, 2, 9, 15) + gs = wx.GridSizer(2, 2, 9, 10) - global username - username = wx.StaticText(self, label="Username") - password = wx.StaticText(self, label="Password") - subreddit = wx.StaticText(self, label="Subreddit") file_to_get = wx.StaticText(self, label="File to get") KEYPASS = wx.StaticText(self, label="Encryption key") filename = wx.StaticText(self, label="Filepath") - get = wx.Button(self, ID_GET_BUTTON, "Get") - saveFile = wx.Button(self,ID_SAVE_FILE_BUTTON, "Save File As") + get = wx.Button(self, ID_GET_BUTTON, "Get") + saveFile = wx.Button(self, ID_SAVE_FILE_BUTTON, "Save File As") getRedditList = wx.Button(self, ID_GET_REDDIT_LIST_BUTTON, "Retrieve List of Stored Files ") global postMessage1 - postMessage1 = wx.StaticText(self,label = "") + postMessage1 = wx.StaticText(self, label="") - self.usernameField = wx.TextCtrl(self) - self.passwordField = wx.TextCtrl(self, style = wx.TE_PASSWORD) - self.subredditField = wx.TextCtrl(self) self.fileToGetField = wx.TextCtrl(self) self.keypassField = wx.TextCtrl(self) self.filepathField = wx.TextCtrl(self) + fgs.AddMany([(file_to_get, 1, wx.EXPAND), (self.fileToGetField, 1, wx.EXPAND), (KEYPASS, 1, wx.EXPAND), + (self.keypassField, 1, wx.EXPAND), (filename, 1, wx.EXPAND), (self.filepathField, 1, wx.EXPAND)]) - fgs.AddMany([(username), (self.usernameField, 1, wx.EXPAND), (password), - (self.passwordField, 1, wx.EXPAND), (subreddit, 1, wx.EXPAND), (self.subredditField, 1, wx.EXPAND), - (file_to_get, 1, wx.EXPAND), (self.fileToGetField, 1, wx.EXPAND), (KEYPASS, 1, wx.EXPAND), - (self.keypassField, 1, wx.EXPAND), (filename, 1, wx.EXPAND), (self.filepathField, 1, wx.EXPAND)]) - - gs.AddMany([(get,1,wx.EXPAND),(saveFile,1,wx.EXPAND), (getRedditList,1,wx.EXPAND), (postMessage1)]) + gs.AddMany([(get, 1, wx.EXPAND), (saveFile, 1, wx.EXPAND), (getRedditList, 1, wx.EXPAND), (postMessage1)]) - - fgs.AddGrowableCol(1, 1) - hbox.Add(fgs, proportion=1, flag=wx.ALL|wx.EXPAND, border=15) - hbox.Add(gs, proportion=1, flag=wx.ALL|wx.EXPAND, border=15) - - + hbox.Add(fgs, proportion=1, flag=wx.ALL | wx.EXPAND, border=15) + hbox.Add(gs, proportion=1, flag=wx.ALL | wx.EXPAND, border=15) + self.SetSizer(hbox) get.Bind(wx.EVT_BUTTON, self.onClickGetItem) - saveFile.Bind(wx.EVT_BUTTON,self.onClickSaveItem) + saveFile.Bind(wx.EVT_BUTTON, self.onClickSaveItem) getRedditList.Bind(wx.EVT_BUTTON, self.onClickGetRedditList) - def onClickSaveItem(self,e): - #self.log.WriteText("CWD: %s\n" % os.getcwd()) - + # noinspection PyUnusedLocal + def onClickSaveItem(self, e): + """Opens a file dialog window so users can choose where to save the file. + Called when user clicks the 'Save File As' button + """ # Create the dialog. In this case the current directory is forced as the starting # directory for the dialog, and no default file name is forced. This can easilly # be changed in your program. This is an 'save' dialog. dlg = wx.FileDialog( - self, message="Save file as ...", defaultDir=os.getcwd(), - defaultFile="", wildcard=wildcard, style=wx.SAVE, - ) - dlg.SetFilename(self.fileToGetField.GetValue()) + self, message="Save file as ...", defaultDir=os.getcwd(), + defaultFile="", wildcard=wildcard, style=wx.FD_SAVE, + ) + + # For legacy support; files used to be uploaded with the whole pathname + # (or did before I changed the upload names) + temp_fn = os.path.basename(self.fileToGetField.GetValue()) + dlg.SetFilename(temp_fn) # This sets the default filter that the user will initially see. Otherwise, # the first filter in the list will be used by default. dlg.SetFilterIndex(2) @@ -304,177 +298,147 @@ def onClickSaveItem(self,e): if dlg.ShowModal() == wx.ID_OK: path = dlg.GetPath() self.filepathField.SetValue(path) - - # Normally, at this point you would save your data using the file and path - # data that the user provided to you, but since we didn't actually start - # with any data to work with, that would be difficult. - # - # The code to do so would be similar to this, assuming 'data' contains - # the data you want to save: - # - # fp = file(path, 'w') # Create file anew - # fp.write(data) - # fp.close() - # - # You might want to add some error checking - - # Note that the current working dir didn't change. This is good since - # that's the way we set it up. - #self.log.WriteText("CWD: %s\n" % os.getcwd()) - - # Destroy the dialog. Don't do this until you are done with it! - # BAD things can happen otherwise! dlg.Destroy() - + # noinspection PyUnusedLocal def onClickGetItem(self, e): - if (self.usernameField.IsEmpty()): - postMessage1.SetLabel("No Username Specified") - elif (self.passwordField.IsEmpty()): - postMessage1.SetLabel("No Password Entered") - elif (self.subredditField.IsEmpty()): - postMessage1.SetLabel("No Subreddit Specified") - elif (self.fileToGetField.IsEmpty()): + """ + Gets the file from the specified Reddit post and downloads to specified file path. + Activated when the 'Get' button is clicked + """ + if (self.fileToGetField.IsEmpty()): postMessage1.SetLabel("No File Specified") elif (self.keypassField.IsEmpty()): postMessage1.SetLabel("No Encryption Key Specified") elif (self.filepathField.IsEmpty()): postMessage1.SetLabel("No Filepath Specified") else: - getItem(self.usernameField.GetValue(), self.passwordField.GetValue(), self.subredditField.GetValue(), - self.filepathField.GetValue(), self.fileToGetField.GetValue(), self.keypassField.GetValue()) - def onClickGetRedditList(self, e): - if(self.subredditField.IsEmpty() is False): - print self.subredditField.GetValue() - frame = RedditList() - pub.sendMessage("subredditListener", subredditName = self.subredditField.GetValue() - , username = self.usernameField.GetValue(), password = self.passwordField.GetValue()) - frame.Show() - else: - postMessage1.SetLabel("No Subreddit Specified") + getItem( # self.subredditField.GetValue(), + self.filepathField.GetValue(), self.fileToGetField.GetValue(), self.keypassField.GetValue()) + + @staticmethod + def onClickGetRedditList(e): + """ + Shows a list of files in a separate window posted to the subreddit. + """ + frame = RedditList() + pub.sendMessage("subredditListener", subreddit_name=SUBREDDIT) + frame.Show() -class MainNotebook(wx.Notebook): - def __init__(self, parent): - wx.Notebook.__init__(self, parent, id=wx.ID_ANY, style= - wx.BK_DEFAULT - #wx.BK_TOP - #wx.BK_BOTTOM - #wx.BK_LEFT - #wx.BK_RIGHT - ) +class MainNotebook(wx.Notebook): + def __init__(self, parent: Union[wx.Window, None] = None): + """ + Constructs a notebook. + :param parent: The parent window of the notebook. + """ + wx.Notebook.__init__(self, parent, id=wx.ID_ANY, style=wx.BK_DEFAULT) self.InitUI() def InitUI(self): - tabOne = PostPanel(self) - self.AddPage(tabOne, "Post") + """Initializes the UI for the notbook""" + tab_one = PostPanel(self) + self.AddPage(tab_one, "Post") + + tab_two = GetPanel(self) + self.AddPage(tab_two, "Get") - tabTwo = GetPanel(self) - self.AddPage(tabTwo, "Get") - self.Bind(wx.EVT_NOTEBOOK_PAGE_CHANGED, self.OnPageChanged) self.Bind(wx.EVT_NOTEBOOK_PAGE_CHANGING, self.OnPageChanging) def OnPageChanged(self, event): + """Event for when tab is finished changing""" old = event.GetOldSelection() new = event.GetSelection() sel = self.GetSelection() - #print 'OnPageChanged, old:%d, new:%d, sel:%d\n' % (old, new, sel) event.Skip() def OnPageChanging(self, event): + """Event for when tab is about to change""" old = event.GetOldSelection() new = event.GetSelection() sel = self.GetSelection() - #print 'OnPageChanging, old:%d, new:%d, sel:%d\n' % (old, new, sel) event.Skip() - + + class MainWindow(wx.Frame): - def __init__(self,parent,title): + def __init__(self, parent, title: str): + """ + Creates a wxPython window. + :param parent: The parent process for this window + :param title: The title of the window. Process will show up in things such as Task Manager or 'ps' with this name + """ super(MainWindow, self).__init__(parent, title=title, size=(500, 375)) - + panel = wx.Panel(self) notebook = MainNotebook(panel) sizer = wx.BoxSizer(wx.VERTICAL) - sizer.Add(notebook, 1, wx.ALL|wx.EXPAND, 5) + sizer.Add(notebook, 1, wx.ALL | wx.EXPAND, 5) panel.SetSizer(sizer) self.Layout() - self.Centre() + self.Centre() self.Show() - -#------------------------------------------------------------------------------ -def postItem(username, password, subreddit, filename, KEYPASS): +# ------------------------------------------------------------------------------ + + +# noinspection PyUnusedLocal,PyPep8Naming +def postItem(filename: str, PASSKEY: str): + """ + Encrypts and then posts a file to Reddit + :param filename: The title of the post to be created + :param PASSKEY: The password used to generate the encryption key + """ filepath = filename k = filename.rfind("/") - filename = filename[k+1:] - - loginMod(username,password,subreddit) - cipher = AESCipher(KEYPASS) - comment = cipher.encrypt_file(filepath) - post_encryption(filename, comment) - postMessage.SetLabel("Done") + filename = filename[k + 1:] + del k + gc.collect() + # loginMod(username, password, subreddit) + cipher = AESCipher(PASSKEY) + encrypt_items = cipher.encrypt_file(filepath) + b64ciphertext = b64encode(encrypt_items[0]) + b64mac = b64encode(encrypt_items[1]) + b64nonce = b64encode(encrypt_items[2]) + del encrypt_items + gc.collect() + post_encryption(filename, b64ciphertext, b64mac, cipher.salt, b64nonce, cipher.argon2params) + postMessage.SetLabel("Done") # Todo: these labels need to disappear after some time postMessage1.SetLabel("Done") -def getItem(username, password, subreddit, filename, file_to_get, KEYPASS): - filepath = filename - #k = filename.rfind("/") - #filename = filename[k+1:] - #filepath = filepath[:k+1] - #temp_fp = filepath - #filepath = filepath + file_to_get - - loginMod(username,password,subreddit) - cipher=AESCipher(KEYPASS) - comment = get_decryption(file_to_get) - +# noinspection PyUnusedLocal,PyPep8Naming +def getItem(save_location: str, file_to_get: str, PASSKEY: Union[str, bytes]): """ - if filename[-1] == ")": - j = filename.rfind("(") - 1 - n = len(filename) - j - filepath = filepath[:-n] + Gets an encrypted file from Reddit + :param save_location: The location to save the file at + :param file_to_get: The name of the file to get. This should be the same as the post title on Reddit + :param PASSKEY: The password used to generate the encryption key. """ - - cipher.decrypt_file(comment, filepath) + filepath = save_location + cipher = AESCipher(PASSKEY) + encrypt_items = get_ciphertext(file_to_get) + cipher.decrypt_to_file(encrypt_items, filepath) + del encrypt_items + gc.collect() postMessage1.SetLabel("Done") postMessage.SetLabel("Done") - -def loginMod(username, password, subreddit): - trying = True - while trying: - try: - _login(username,password) - trying = False - except praw.errors.InvalidUserPass: - postMessage.SetLabel("Wrong Password") - postMessage1.SetLabel("Wrong Password") - while checkForMod(username, subreddit): - postMessage.SetLabel("Not a Moderator of the Subreddit") - postMessage1.SetLabel("Not a Moderator of the Subreddit") - -def _login(username, password): - r.login(username,password) - -def checkForMod(user, subreddit): - - subr = r.get_subreddit(subreddit) - mods = subr.get_moderators() - - for mod in mods: - if mod == user.lower(): - return True - return False - -if __name__ == '__main__': - +def StartApp(): + """ + Starts the app UI. Does not execute in a separate thread. Code after this function will only execute after + the window is closed. + """ app = wx.App() MainWindow(None, title='subreddit') app.MainLoop() + + +if __name__ == '__main__': + StartApp() diff --git a/crypt.py b/crypt.py index 59eba33..ddb7118 100644 --- a/crypt.py +++ b/crypt.py @@ -1,57 +1,154 @@ -import hashlib -import os -import base64 +from base64 import b64decode +from typing import Tuple, List, Union +import argon2.low_level from Crypto.Cipher import AES -from Crypto import Random +from argon2 import PasswordHasher, Parameters, Type class AESCipher(object): - def __init__(self, key): - #self.bs = 32 - self.key = hashlib.sha256(key).digest() #turns the password into a 32char long key - - #need to make our string divisible by 16 for AES encryption - def pad(self, s): - n = AES.block_size - len(s) % AES.block_size - n = AES.block_size if n == 0 else n - return s + chr(n) * n - - def remove_pad(self, m): - n = int(m[-1].encode('hex'), AES.block_size) - return m[: -1 * n] - - #encrypts plaintext and generates IV (initialization vector) - def encrypt(self, plaintext): - plaintext = self.pad(plaintext) - iv = Random.new().read(AES.block_size) - cipher = AES.new(self.key, AES.MODE_CBC, iv) - return iv + cipher.encrypt(plaintext) - - #derypts ciphertexts - def decrypt(self, ciphertext): - iv = ciphertext[:AES.block_size] - cipher = AES.new(self.key, AES.MODE_CBC, iv) - plaintext = cipher.decrypt(ciphertext[AES.block_size:]) - return self.remove_pad(plaintext) - - #encrypts a file and returns a comment to be posted - def encrypt_file(self, file_path): + hasher = PasswordHasher() + """Mainly for internal use. So we don't have to remake this object every encryption/decryption""" + def __init__(self, key: str): + """ + Constructor for an AESCipher object + :param key: The password to use as a key + """ + # argon2 outputs a single string with all parameters delimited by a '$' + self.argon2 = self.hasher.hash(key) + """The argon2 parameters, salt, and hash, as output by PasswordHasher""" + + self.argon2params, self.salt, self.hash = self.extract_parameters(self.argon2) + + # argon2-cffi encodes the values in base64, so we decode it here to get our byte values + # And we need to add padding '=' because reasons b64 needs that number of chars + self.key: bytes = b64decode(self.hash + '=') # Should be 32 bytes long + """The key for encrypting, in raw byte form; 32 bytes long""" + self.secret = key + """The password hashed to generate the key""" + + # encrypts a file and returns a comment to be posted + def encrypt_file(self, file_path: str) -> Tuple[bytes, bytes, bytes]: + """ + Encrypts a file and returns the ciphertext and associated MAC + :param file_path: The path to the file to encrypt + :return: A list containing [ciphertext, MAC, nonce] + """ + cipher = AES.new(self.key, AES.MODE_GCM) + ciphertext = b'' with open(file_path, 'rb') as fo: - plaintext = fo.read() - enc = self.encrypt(plaintext) - comment = base64.b64encode(enc) - #comment = enc.decode('ISO-8859-1').encode('ascii') - return comment - - #takes in a comment to be posted and decrypts it into a file - def decrypt_file(self, comment, file_path): - - ciphertext = base64.b64decode(comment) - #ciphertext = comment.decode('ascii').encode('ISO-8859-1') - dec = self.decrypt(ciphertext) + while True: + plaintext = fo.read(20000) + if not plaintext: + break + ciphertext += cipher.encrypt(plaintext) + mac = cipher.digest() + # comment = enc.decode('ISO-8859-1').encode('ascii') + print('\nEncryption info:\nMAC: ', mac, '\nSalt: ', self.salt, '\nKey: ', self.hash, '\nSecret: ', + self.secret) + return ciphertext, mac, cipher.nonce + + # takes in a comment to be posted and decrypts it into a file + + _STR_TYPE_TO_TYPE = {"Type.ID": Type.ID, "Type.I": Type.I, "Type.D": Type.D} + + def decrypt_to_file(self, encrypt_items: Tuple[bytes, List[str]], file_path: str): + """ + Decrypts a file encrypted in AES-GCM and outputs the result to the given filepath + :parameter encrypt_items: A Tuple containing [ciphertext, argon2 parameters] + :parameter file_path: The file path to output the decrypted file to + """ + ciphertext = encrypt_items[0] + mac = encrypt_items[1][0] + salt = encrypt_items[1][1] + nonce = encrypt_items[1][9] + # ciphertext = comment.decode('ascii').encode('ISO-8859-1') + # Format is MAC$salt$time cost$memory cost$parallelism$hash length$salt length$argon2 type$argon2 version + dec = self._decrypt(ciphertext, b64decode(mac), b64decode(salt), b64decode(nonce), + Parameters(time_cost=int(encrypt_items[1][2]), + memory_cost=int(encrypt_items[1][3]), + parallelism=int(encrypt_items[1][4]), + hash_len=int(encrypt_items[1][5]), + salt_len=int(encrypt_items[1][6]), + type=self._STR_TYPE_TO_TYPE[encrypt_items[1][7]], + version=int(encrypt_items[1][8]) + ) + ) with open(file_path, 'wb') as fo: fo.write(dec) + # decrypts ciphertexts + def _decrypt(self, ciphertext: bytes, mac_tag: bytes, salt: bytes, nonce: bytes, + argon2_params: Parameters) -> bytes: + """ + Returns the decrypted ciphertext + :param ciphertext: The ciphertext to decrypt + :param mac_tag: The MAC for the ciphertext + :param salt: The salt used for the key + :param nonce: The nonce used by the AES algorithm + :return: The decrypted information + """ + cipher = AES.new( + b64decode(argon2.low_level.hash_secret(self.secret.encode('utf-8'), salt, + argon2_params.time_cost, argon2_params.memory_cost, + argon2_params.parallelism, argon2_params.hash_len, + argon2_params.type, argon2_params.version + ).decode('utf-8').split('$')[5] + '=' + ), + AES.MODE_GCM, nonce=nonce) + return cipher.decrypt_and_verify(ciphertext, mac_tag) + + _NAME_TO_TYPE = {"argon2id": Type.ID, "argon2i": Type.I, "argon2d": Type.D} + """Dictionary quick translation of an argon2 type to appropriate enum""" + + @classmethod + def extract_parameters(cls, argon2item: str) -> List[Union[Parameters, str]]: + """ + Extracts argon2 parameters and returns the salt and hash + :param argon2item: The argon2 item returned from using argon2 hashing (i.e., argon2.PassswordHasher) + :return: A list containing [argon2 parameters, salt, hash] + """ + parts = argon2item.split("$") + + # Backwards compatibility for Argon v1.2 hashes + if len(parts) == 5: + parts.insert(2, "v=18") + + argon2_type = cls._NAME_TO_TYPE[parts[1]] + + kvs = { + k: int(v) + for k, v in ( + s.split("=") for s in [parts[2]] + parts[3].split(",") + ) + } + + return [Parameters( + type=argon2_type, + salt_len=cls._decoded_str_len(len(parts[4])), + hash_len=cls._decoded_str_len(len(parts[5])), + version=kvs["v"], + time_cost=kvs["t"], + memory_cost=kvs["m"], + parallelism=kvs["p"], + ), parts[4], parts[5]] + + @classmethod + def _decoded_str_len(cls, str_len: int) -> int: + """ + Compute how long an encoded string of length *l* becomes. + :param str_len Length of encoded string + :return Length of decoded string + """ + rem = str_len % 4 + + if rem == 3: + last_group_len = 2 + elif rem == 2: + last_group_len = 1 + else: + last_group_len = 0 + + return str_len // 4 * 3 + last_group_len diff --git a/example_praw.ini b/example_praw.ini new file mode 100644 index 0000000..33ec48e --- /dev/null +++ b/example_praw.ini @@ -0,0 +1,7 @@ +[reddit storage bot] +client_id = p-jcoLKBynTLew +client_secret = gko_LXELoV07ZBNUXrvWZfzE3aI +user_agent = platform:app_id:versin_id (By u/reddit) +username = my-reddit-username +password = my-reddit-password +subreddit = my-subreddit \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..28d10ec --- /dev/null +++ b/main.py @@ -0,0 +1,3 @@ +from RedditStorage import StartApp +if __name__ == '__main__': + StartApp() diff --git a/reddit.py b/reddit.py index b22d0a5..886d896 100644 --- a/reddit.py +++ b/reddit.py @@ -1,65 +1,114 @@ +import gc +import os.path +from base64 import b64encode, b64decode import praw - +from typing import Tuple, List +from time import sleep +from secrets import SystemRandom from redditglobals import * - - -def post_encryption(filename, encryption): - subreddit = r.get_subreddit(SUBREDDIT) - does_not_exist = True - file_submissions = r.search(filename, SUBREDDIT) +from argon2 import Parameters # For typehints + + +# Tuple containing [ciphertext, MAC, salt, time_cost, memory_cost, parallelism, hash_len, argon2type, argon2version] +def post_encryption(post_title, ciphertext: bytes, MAC: bytes, salt: str, nonce: bytes, argon2_params: Parameters): + """ + Posts encrypted ciphertext to a subreddit defined in redditglobals.py + :param post_title: The title of the post to make + :param ciphertext: Encrypted text to post, encoded in base64 + :param MAC: Associated MAC for the ciphertext, encoded in base64 + :param salt: Salt used to hash the password, encoded in base64 utf-8 + :param nonce: Nonce used by the AES algorithm + :param argon2_params: Argon2 parameters used to hash the password + """ + subreddit = REDDIT.subreddit(SUBREDDIT) + does_not_exist = True + post_title = os.path.basename(post_title) + file_submissions = subreddit.search(post_title, SUBREDDIT) # getting the submission of the file if it exists already count = 0 + filename_lower = post_title.lower() for submission in file_submissions: - if filename.lower() in submission.title.lower(): + if filename_lower in submission.title.lower(): # Looks for submissions with filename inside it count += 1 does_not_exist = False - # create submission if does not exist + # create submission. Post text will be params necessary to recreate the hash in argon2 + # Format is MAC$salt$time cost$memory cost$parallelism$hash length$salt length$argon2 type$argon2 version$nonce + # Ex: examplemac$examplesalt$20$45$4$32$16$Type.ID$19 + post_text = f'{MAC.decode("utf-8")}${salt}==${argon2_params.time_cost}${argon2_params.memory_cost}$' \ + f'{argon2_params.parallelism}${argon2_params.hash_len}${argon2_params.salt_len}${argon2_params.type}${argon2_params.version}${nonce.decode("utf-8")}' if does_not_exist: - file_post = r.submit(SUBREDDIT, filename, " ") - else: - file_post = r.submit(SUBREDDIT, filename + " (" + str(count) + ")", " ") - + file_post = subreddit.submit(post_title, selftext=post_text) + else: # if file exists, then add a number to the end of the filename + file_post = subreddit.submit(post_title + " (" + str(count) + ")", selftext=post_text) + del post_text + gc.collect() # going to be splitting the encryption since the comment limit is 10000 characters # this is the first-level comment - current_comment = file_post.add_comment(encryption[:10000]) - encryption = encryption[10000:] - - #if it does not fit, then we will add a child comment to it and repeat - if len(encryption) != 0: - - while len(encryption) > 10000: - #to-do - current_comment = current_comment.reply(encryption[:10000]) - encryption = encryption[10000:] - - if len(encryption) > 0: - current_comment.reply(encryption) - - -def get_decryption(filename): - decryption = '' - - subreddit = r.get_subreddit(SUBREDDIT) - comments = subreddit.get_comments() - - file_submissions = r.search(filename, SUBREDDIT) - + print(f'\nNumber of comments to post: {len(ciphertext) / 10000 + 1}\n') + rand_num = SystemRandom() + current_comment = file_post.reply(ciphertext[:10000]) + cur_index = 10000 + ciphertext_len = len(ciphertext) + num_comments = 1 + next_sleep = rand_num.randrange(10, 22) + print(f'\nPosted {num_comments} comment\n') + # Tries to reply with max chars for comments (10,000) until there isn't enough in the buffer + while ciphertext_len - cur_index >= 10000: # Keep replying with max chars until we can't + current_comment = current_comment.reply(ciphertext[cur_index:cur_index + 10000]) + num_comments += 1 + print(f'Posted {num_comments} comment\n') + if num_comments == next_sleep: + print('Sleeping') + next_sleep += rand_num.randrange(1, 10) # Stop commenting after 1-10 comments + sleep(float(rand_num.randrange(200, 450)) / 10.0) # 20.0-45.0 second sleep + cur_index += 10000 + if ciphertext_len > cur_index: + current_comment.reply(ciphertext[cur_index:]) + del ciphertext + gc.collect() + + +def get_ciphertext(post_title) -> Tuple[bytes, List[str]]: + """ + Returns the ciphertext and MAC from given post title + :param post_title: Name of the post to find + :return: A tuple containing [ciphertext, argon2 parameters]. Only ciphertext is decoded from base64 + """ + ciphertext = '' + + subreddit = REDDIT.subreddit(SUBREDDIT) + + file_submissions = subreddit.search(post_title) + subm = [] + submissions_found = 0 # find the corresponding post for the file + filename_lower = post_title.lower() for submission in file_submissions: - - if submission.title.lower() == filename.lower(): - subm = submission - break - - # level the comments - subm.replace_more_comments(limit=None, threshold=0) - comments = praw.helpers.flatten_tree(subm.comments) - - for comment in comments: - decryption = decryption + comment.body - - return decryption - + if filename_lower in submission.title.lower(): + subm.append(submission) + submissions_found += 1 + if not submissions_found: + # todo: get an actual exception type + raise Exception("Couldn't find file") + if submissions_found == 1: # Found only 1 file + # level the comments + subm[0].comments.replace_more(limit=None, threshold=0) + comments = subm[0].comments.list() + params: List[str] = subm[0].selftext.split('$') + + for comment in comments: + ciphertext: str = ciphertext + comment.body + + return b64decode(ciphertext), params + else: # More than 1 similar file found; + # todo: Need to show a dialog window so they can select which version to dl (or all) + subm[0].comments.replace_more(limit=None, threshold=0) + comments = subm[0].comments.list() + params: List[str] = subm[0].selftext.split('$') + for comment in comments: + ciphertext: str = ciphertext + comment.body + + return b64decode(ciphertext), params diff --git a/redditglobals.py b/redditglobals.py index f9b7396..3d218cd 100644 --- a/redditglobals.py +++ b/redditglobals.py @@ -1,11 +1,12 @@ import praw - -global USERAGENT,USERNAME,PASSWORD,SUBREDDIT,r - +from configparser import ConfigParser +global USERAGENT, SUBREDDIT, REDDIT +config = ConfigParser() +config.read('praw.ini') USERAGENT = "reddit storage bot" -USERNAME = "" -PASSWORD = "" -SUBREDDIT = "redditstoragetest" -#MAXPOSTS = 100 +"""The useragent of the bot. See https://en.wikipedia.org/wiki/User_agent for more details""" +SUBREDDIT = config['reddit storage bot']['subreddit'] +"""The name of the subreddit files are posted to.""" -r = praw.Reddit(USERAGENT) +REDDIT = praw.Reddit(USERAGENT) +"""Praw instance for accessing Reddit."""