__filename__ = "webinterface.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "0.0.1" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" import json import time import os from datetime import datetime from shutil import copyfile from pprint import pprint from person import personBoxJson from utils import getNicknameFromActor from utils import getDomainFromActor from posts import getPersonBox from follow import isFollowingActor from webfinger import webfingerHandle from posts import getUserUrl from posts import parseUserFeed from session import getJson from auth import createPassword def htmlGetLoginCredentials(loginParams: str,lastLoginTime: int) -> (str,str): """Receives login credentials via HTTPServer POST """ if not loginParams.startswith('username='): return None,None # minimum time between login attempts currTime=int(time.time()) if currTime str: if not os.path.isfile(baseDir+'/accounts/login.png'): copyfile(baseDir+'/img/login.png',baseDir+'/accounts/login.png') if os.path.isfile(baseDir+'/img/login-background.png'): if not os.path.isfile(baseDir+'/accounts/login-background.png'): copyfile(baseDir+'/img/login-background.png',baseDir+'/accounts/login-background.png') loginText='

Welcome. Please enter your login details below.

' if os.path.isfile(baseDir+'/accounts/login.txt'): with open(baseDir+'/accounts/login.txt', 'r') as file: loginText = '

'+file.read()+'

' with open(baseDir+'/epicyon-login.css', 'r') as cssFile: loginCSS = cssFile.read() loginForm=htmlHeader(loginCSS) loginForm+= \ '
' \ '
' \ ' login image'+ \ loginText+ \ '
' \ '' \ '
' \ ' ' \ ' ' \ '' \ ' ' \ ' ' \ '' \ ' ' \ '
' \ '
' loginForm+=htmlFooter() return loginForm def htmlNewPost(baseDir: str,path: str,inReplyTo: str) -> str: replyStr='' if not path.endswith('/newshare'): if not inReplyTo: newPostText='

Enter your post text below.

' else: newPostText='

Enter your reply to this post below.

' replyStr='' else: newPostText='

Enter the details for your shared item below.

' if os.path.isfile(baseDir+'/accounts/newpost.txt'): with open(baseDir+'/accounts/newpost.txt', 'r') as file: newPostText = '

'+file.read()+'

' with open(baseDir+'/epicyon-profile.css', 'r') as cssFile: newPostCSS = cssFile.read() pathBase=path.replace('/newpost','').replace('/newshare','').replace('/newunlisted','').replace('/newfollowers','').replace('/newdm','') scopeIcon='scope_public.png' scopeDescription='Public' placeholderSubject='Subject or Content Warning (optional)...' placeholderMessage='Write something...' extraFields='' endpoint='newpost' if path.endswith('/newunlisted'): scopeIcon='scope_unlisted.png' scopeDescription='Unlisted' endpoint='newunlisted' if path.endswith('/newfollowers'): scopeIcon='scope_followers.png' scopeDescription='Followers Only' endpoint='newfollowers' if path.endswith('/newdm'): scopeIcon='scope_dm.png' scopeDescription='Direct Message' endpoint='newdm' if path.endswith('/newshare'): scopeIcon='scope_share.png' scopeDescription='Shared Item' placeholderSubject='Name of the shared item...' placeholderMessage='Description of the item being shared...' endpoint='newshare' extraFields= \ '
' \ ' ' \ ' ' \ ' ' \ '
' \ '' newPostForm=htmlHeader(newPostCSS) # only show the share option if this is not a reply shareOptionOnDropdown='' if not replyStr: shareOptionOnDropdown='Share
Describe a shared item
' newPostForm+= \ '
' \ '
' \ ' ' \ '
' \ ' ' \ ' ' \ ' ' \ '
'+ \ replyStr+ \ ' ' \ '' \ ' ' \ ''+extraFields+ \ '
' \ ' ' \ ' ' \ '
' \ '
' \ '
' newPostForm+=htmlFooter() return newPostForm def htmlHeader(css=None,lang='en') -> str: if not css: htmlStr= \ '\n' \ '\n' \ ' \n' \ ' \n' \ ' \n' else: htmlStr= \ '\n' \ '\n' \ ' \n' \ ' \n' \ ' \n' return htmlStr def htmlFooter() -> str: htmlStr= \ ' \n' \ '\n' return htmlStr def htmlProfilePosts(baseDir: str,httpPrefix: str, \ authorized: bool,ocapAlways: bool, \ nickname: str,domain: str,port: int, \ session,wfRequest: {},personCache: {}) -> str: """Shows posts on the profile screen """ profileStr='' outboxFeed= \ personBoxJson(baseDir,domain, \ port,'/users/'+nickname+'/outbox?page=1', \ httpPrefix, \ 4, 'outbox', \ authorized, \ ocapAlways) profileStr+='' for item in outboxFeed['orderedItems']: if item['type']=='Create' or item['type']=='Announce': profileStr+= \ individualPostAsHtml(baseDir,session,wfRequest,personCache, \ nickname,domain,port,item,None,True,False) return profileStr def htmlProfileFollowing(baseDir: str,httpPrefix: str, \ authorized: bool,ocapAlways: bool, \ nickname: str,domain: str,port: int, \ session,wfRequest: {},personCache: {}, \ followingJson: {}) -> str: """Shows following on the profile screen """ profileStr='' for item in followingJson['orderedItems']: profileStr+=individualFollowAsHtml(session,wfRequest,personCache,domain,item) return profileStr def htmlProfileRoles(nickname: str,domain: str,rolesJson: {}) -> str: """Shows roles on the profile screen """ profileStr='' for project,rolesList in rolesJson.items(): profileStr+='

'+project+'

' for role in rolesList: profileStr+='

'+role+'

' profileStr+='
' if len(profileStr)==0: profileStr+='

@'+nickname+'@'+domain+' has no roles assigned

' else: profileStr='
'+profileStr+'
' return profileStr def htmlProfileSkills(nickname: str,domain: str,skillsJson: {}) -> str: """Shows skills on the profile screen """ profileStr='' for skill,level in skillsJson.items(): profileStr+='
'+skill+'

' if len(profileStr)==0: profileStr+='

@'+nickname+'@'+domain+' has no skills assigned

' else: profileStr='
'+profileStr+'
' return profileStr def htmlProfileShares(nickname: str,domain: str,sharesJson: {}) -> str: """Shows shares on the profile screen """ profileStr='' for item in sharesJson['orderedItems']: profileStr+='
' profileStr+='' profileStr+='' profileStr+='Item image' profileStr+='

'+item['summary']+'

' profileStr+='

Type: '+item['itemType']+' ' profileStr+='Category: '+item['category']+' ' profileStr+='Location: '+item['location']+'

' profileStr+='
' if len(profileStr)==0: profileStr+='

@'+nickname+'@'+domain+' is not sharing any items

' else: profileStr='
'+profileStr+'
' return profileStr def htmlProfile(baseDir: str,httpPrefix: str,authorized: bool, \ ocapAlways: bool,profileJson: {},selected: str, \ session,wfRequest: {},personCache: {}, \ extraJson=None) -> str: """Show the profile page as html """ nickname=profileJson['name'] if not nickname: return "" preferredName=profileJson['preferredUsername'] domain,port=getDomainFromActor(profileJson['id']) if not domain: return "" domainFull=domain if port: domainFull=domain+':'+str(port) profileDescription=profileJson['summary'] profileDescription='A test description' postsButton='button' followingButton='button' followersButton='button' rolesButton='button' skillsButton='button' sharesButton='button' if selected=='posts': postsButton='buttonselected' elif selected=='following': followingButton='buttonselected' elif selected=='followers': followersButton='buttonselected' elif selected=='roles': rolesButton='buttonselected' elif selected=='skills': skillsButton='buttonselected' elif selected=='shares': sharesButton='buttonselected' loginButton='' followApprovalsSection='' followApprovals='' linkToTimelineStart='' linkToTimelineEnd='' if not authorized: loginButton='
' else: linkToTimelineStart='' linkToTimelineEnd='' # are there any follow requests? followRequestsFilename=baseDir+'/accounts/'+nickname+'@'+domain+'/followrequests.txt' if os.path.isfile(followRequestsFilename): with open(followRequestsFilename,'r') as f: for line in f: if len(line)>0: # show a star on the followers tab followApprovals='' break if selected=='followers': if len(followApprovals)>0: with open(followRequestsFilename,'r') as f: for followerHandle in f: if len(line)>0: if '://' in followerHandle: followerActor=followerHandle else: followerActor=httpPrefix+'://'+followerHandle.split('@')[1]+'/users/'+followerHandle.split('@')[0] basePath=httpPrefix+'://'+domainFull+'/users/'+nickname followApprovalsSection+='
' followApprovalsSection+='' followApprovalsSection+=''+followerHandle+'' followApprovalsSection+='' followApprovalsSection+='' followApprovalsSection+='' followApprovalsSection+='' followApprovalsSection+='
' actor=profileJson['id'] profileStr= \ linkToTimelineStart+ \ '
' \ '
' \ ' '+nickname+'@'+domainFull+'' \ '

'+preferredName+'

' \ '

@'+nickname+'@'+domainFull+'

' \ '

'+profileDescription+'

'+ \ loginButton+ \ '
' \ '
'+ \ linkToTimelineEnd+ \ '
\n' \ '
' \ ' ' \ ' ' \ ' ' \ ' ' \ ' ' \ ' ' \ '
' \ '
' profileStr+=followApprovalsSection with open(baseDir+'/epicyon-profile.css', 'r') as cssFile: profileStyle = cssFile.read().replace('image.png',actor+'/image.png') if selected=='posts': profileStr+= \ htmlProfilePosts(baseDir,httpPrefix,authorized, \ ocapAlways,nickname,domain,port, \ session,wfRequest,personCache) if selected=='following' or selected=='followers': profileStr+= \ htmlProfileFollowing(baseDir,httpPrefix, \ authorized,ocapAlways,nickname, \ domain,port,session, \ wfRequest,personCache,extraJson) if selected=='roles': profileStr+= \ htmlProfileRoles(nickname,domainFull,extraJson) if selected=='skills': profileStr+= \ htmlProfileSkills(nickname,domainFull,extraJson) if selected=='shares': profileStr+= \ htmlProfileShares(nickname,domainFull,extraJson) profileStr=htmlHeader(profileStyle)+profileStr+htmlFooter() return profileStr def individualFollowAsHtml(session,wfRequest: {}, \ personCache: {},domain: str, \ followUrl: str) -> str: nickname=getNicknameFromActor(followUrl) domain,port=getDomainFromActor(followUrl) titleStr='@'+nickname+'@'+domain avatarUrl=followUrl+'/avatar.png' if domain not in followUrl: inboxUrl,pubKeyId,pubKey,fromPersonId,sharedInbox,capabilityAcquisition,avatarUrl2,preferredName = \ getPersonBox(session,wfRequest,personCache,'outbox') if avatarUrl2: avatarUrl=avatarUrl2 if preferredName: titleStr=preferredName+' '+titleStr return \ '
\n' \ '' \ 'Avatar\n'+ \ '

'+titleStr+'

'+ \ '
\n' def contentWarningScript() -> str: """Returns a script used for content warnings """ script= \ 'function showContentWarning(postID) {' \ ' var x = document.getElementById(postID);' \ ' if (x.style.display === "none") {' \ ' x.style.display = "block";' \ ' } else {' \ ' x.style.display = "none";' \ ' }' \ '}' return script def individualPostAsHtml(baseDir: str, \ session,wfRequest: {},personCache: {}, \ nickname: str,domain: str,port: int, \ postJsonObject: {}, \ avatarUrl: str, showAvatarDropdown: bool, showIcons=False) -> str: """ Shows a single post as html """ titleStr='' if postJsonObject['type']=='Announce': if postJsonObject.get('object'): if isinstance(postJsonObject['object'], str): # get the announced post asHeader = {'Accept': 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'} announcedJson = getJson(session,postJsonObject['object'],asHeader,None) if announcedJson: if not announcedJson.get('type'): return '' if announcedJson['type']!='Create': return '' actorNickname=getNicknameFromActor(postJsonObject['actor']) actorDomain,actorPort=getDomainFromActor(postJsonObject['actor']) titleStr+='@'+actorNickname+'@'+actorDomain+' announced:
' postJsonObject=announcedJson else: return '' else: return '' if not isinstance(postJsonObject['object'], dict): return '' avatarPosition='' containerClass='container' containerClassIcons='containericons' timeClass='time-right' actorNickname=getNicknameFromActor(postJsonObject['actor']) actorDomain,actorPort=getDomainFromActor(postJsonObject['actor']) titleStr+='@'+actorNickname+'@'+actorDomain if postJsonObject['object']['inReplyTo']: containerClassIcons='containericons darker' containerClass='container darker' avatarPosition=' class="right"' timeClass='time-left' if '/statuses/' in postJsonObject['object']['inReplyTo']: replyNickname=getNicknameFromActor(postJsonObject['object']['inReplyTo']) replyDomain,replyPort=getDomainFromActor(postJsonObject['object']['inReplyTo']) if replyNickname and replyDomain: titleStr+=' replying to @'+replyNickname+'@'+replyDomain+'' else: titleStr+=' replying to '+postJsonObject['object']['inReplyTo'] attachmentStr='' if postJsonObject['object']['attachment']: if isinstance(postJsonObject['object']['attachment'], list): attachmentCtr=0 for attach in postJsonObject['object']['attachment']: if attach.get('mediaType') and attach.get('url'): mediaType=attach['mediaType'] imageDescription='' if attach.get('name'): imageDescription=attach['name'] if mediaType=='image/png' or \ mediaType=='image/jpeg' or \ mediaType=='image/gif': if attach['url'].endswith('.png') or \ attach['url'].endswith('.jpg') or \ attach['url'].endswith('.jpeg') or \ attach['url'].endswith('.gif'): if attachmentCtr>0: attachmentStr+='
' attachmentStr+= \ '' \ ''+imageDescription+'\n' attachmentCtr+=1 if not avatarUrl: avatarUrl=postJsonObject['actor']+'/avatar.png' fullDomain=domain if port!=80 and port!=443: fullDomain=domain+':'+str(port) if fullDomain not in postJsonObject['actor']: inboxUrl,pubKeyId,pubKey,fromPersonId,sharedInbox,capabilityAcquisition,avatarUrl2,preferredName = \ getPersonBox(session,wfRequest,personCache,'outbox') if avatarUrl2: avatarUrl=avatarUrl2 if preferredName: titleStr=preferredName+' '+titleStr avatarDropdown= \ ' ' \ ' Avatar' if showAvatarDropdown and fullDomain+'/users/'+nickname not in postJsonObject['actor']: # if not following then show "Follow" in the dropdown followUnfollowStr='Follow' # if following then show "Unfollow" in the dropdown if isFollowingActor(baseDir,nickname,domain,postJsonObject['actor']): followUnfollowStr='Unfollow' avatarDropdown= \ ' ' publishedStr=postJsonObject['object']['published'] datetimeObject = datetime.strptime(publishedStr,"%Y-%m-%dT%H:%M:%SZ") publishedStr=datetimeObject.strftime("%a %b %d, %H:%M") footerStr=''+publishedStr+'\n' if showIcons: footerStr='
' footerStr+='' footerStr+='' footerStr+='' footerStr+='' footerStr+='' footerStr+='' footerStr+=''+publishedStr+'' footerStr+='
' if not postJsonObject['object']['sensitive']: contentStr=postJsonObject['object']['content']+attachmentStr else: postID='post'+str(createPassword(8)) contentStr='' if postJsonObject['object'].get('summary'): contentStr+=''+postJsonObject['object']['summary']+' ' else: contentStr+='Sensitive ' contentStr+='' contentStr+='
' contentStr+=postJsonObject['object']['content']+attachmentStr contentStr+='
' return \ '
\n'+ \ avatarDropdown+ \ '

'+titleStr+'

'+ \ contentStr+footerStr+ \ '
\n' def htmlTimeline(session,baseDir: str,wfRequest: {},personCache: {}, \ nickname: str,domain: str,port: int,timelineJson: {}, \ boxName: str) -> str: """Show the timeline as html """ with open(baseDir+'/epicyon-profile.css', 'r') as cssFile: profileStyle = \ cssFile.read().replace('banner.png', \ '/users/'+nickname+'/banner.png') inboxButton='button' sentButton='button' if boxName=='inbox': inboxButton='buttonselected' elif boxName=='outbox': sentButton='buttonselected' actor='/users/'+nickname showIndividualPostIcons=True if boxName=='inbox': showIndividualPostIcons=True followApprovals='' followRequestsFilename=baseDir+'/accounts/'+nickname+'@'+domain+'/followrequests.txt' if os.path.isfile(followRequestsFilename): with open(followRequestsFilename,'r') as f: for line in f: if len(line)>0: # show follow approvals icon followApprovals='Approve follow requests' break tlStr=htmlHeader(profileStyle) tlStr+= \ '' \ '
' \ '
' \ '
\n'+ \ ' ' \ ' ' \ ' Create a new post'+ \ ' Search and follow'+ \ followApprovals+ \ '
' tlStr+='' for item in timelineJson['orderedItems']: if item['type']=='Create' or item['type']=='Announce': tlStr+=individualPostAsHtml(baseDir,session,wfRequest,personCache, \ nickname,domain,port,item,None,True,showIndividualPostIcons) tlStr+=htmlFooter() return tlStr def htmlInbox(session,baseDir: str,wfRequest: {},personCache: {}, \ nickname: str,domain: str,port: int,inboxJson: {}) -> str: """Show the inbox as html """ return htmlTimeline(session,baseDir,wfRequest,personCache, \ nickname,domain,port,inboxJson,'inbox') def htmlOutbox(session,baseDir: str,wfRequest: {},personCache: {}, \ nickname: str,domain: str,port: int,outboxJson: {}) -> str: """Show the Outbox as html """ return htmlTimeline(session,baseDir,wfRequest,personCache, \ nickname,domain,port,outboxJson,'outbox') def htmlIndividualPost(baseDir: str,session,wfRequest: {},personCache: {}, \ nickname: str,domain: str,port: int,postJsonObject: {}) -> str: """Show an individual post as html """ return htmlHeader()+ \ individualPostAsHtml(baseDir,session,wfRequest,personCache, \ nickname,domain,port,postJsonObject,None,True,False)+ \ htmlFooter() def htmlPostReplies(postJsonObject: {}) -> str: """Show the replies to an individual post as html """ return htmlHeader()+"

Replies

"+htmlFooter() def htmlFollowConfirm(baseDir: str,originPathStr: str,followActor: str,followProfileUrl: str) -> str: """Asks to confirm a follow """ followDomain,port=getDomainFromActor(followActor) if os.path.isfile(baseDir+'/img/follow-background.png'): if not os.path.isfile(baseDir+'/accounts/follow-background.png'): copyfile(baseDir+'/img/follow-background.png',baseDir+'/accounts/follow-background.png') with open(baseDir+'/epicyon-follow.css', 'r') as cssFile: profileStyle = cssFile.read() followStr=htmlHeader(profileStyle) followStr+='
' followStr+='
' followStr+='
' followStr+=' ' followStr+=' ' followStr+='

Follow '+getNicknameFromActor(followActor)+'@'+followDomain+' ?

' followStr+= \ '
' \ ' ' \ ' ' \ ' ' \ '
' followStr+='
' followStr+='
' followStr+='
' followStr+=htmlFooter() return followStr def htmlUnfollowConfirm(baseDir: str,originPathStr: str,followActor: str,followProfileUrl: str) -> str: """Asks to confirm unfollowing an actor """ followDomain,port=getDomainFromActor(followActor) if os.path.isfile(baseDir+'/img/follow-background.png'): if not os.path.isfile(baseDir+'/accounts/follow-background.png'): copyfile(baseDir+'/img/follow-background.png',baseDir+'/accounts/follow-background.png') with open(baseDir+'/epicyon-follow.css', 'r') as cssFile: profileStyle = cssFile.read() followStr=htmlHeader(profileStyle) followStr+='
' followStr+='
' followStr+='
' followStr+=' ' followStr+=' ' followStr+='

Stop following '+getNicknameFromActor(followActor)+'@'+followDomain+' ?

' followStr+= \ '
' \ ' ' \ ' ' \ ' ' \ '
' followStr+='
' followStr+='
' followStr+='
' followStr+=htmlFooter() return followStr def htmlSearch(baseDir: str,path: str) -> str: """Search called from the timeline icon """ actor=path.replace('/search','') nickname=getNicknameFromActor(actor) domain,port=getDomainFromActor(actor) if os.path.isfile(baseDir+'/img/search-background.png'): if not os.path.isfile(baseDir+'/accounts/search-background.png'): copyfile(baseDir+'/img/search-background.png',baseDir+'/accounts/search-background.png') with open(baseDir+'/epicyon-follow.css', 'r') as cssFile: profileStyle = cssFile.read() followStr=htmlHeader(profileStyle) followStr+='
' followStr+='
' followStr+='
' followStr+='

Enter an address to search for

' followStr+= \ '
' \ ' ' \ '
' \ ' ' \ ' ' \ '
' followStr+='
' followStr+='
' followStr+='
' followStr+=htmlFooter() return followStr def htmlProfileAfterSearch(baseDir: str,path: str,httpPrefix: str, \ nickname: str,domain: str,port: int, \ profileHandle: str, \ session,wfRequest: {},personCache: {}, debug: bool) -> str: """Show a profile page after a search for a fediverse address """ if '/users/' in profileHandle: searchNickname=getNicknameFromActor(profileHandle) searchDomain,searchPort=getDomainFromActor(profileHandle) else: if '@' not in profileHandle: if debug: print('DEBUG: no @ in '+profileHandle) return None if profileHandle.startswith('@'): profileHandle=profileHandle[1:] if '@' not in profileHandle: if debug: print('DEBUG: no @ in '+profileHandle) return None searchNickname=profileHandle.split('@')[0] searchDomain=profileHandle.split('@')[1] searchPort=None if ':' in searchDomain: searchPort=int(searchDomain.split(':')[1]) searchDomain=searchDomain.split(':')[0] if not searchNickname: if debug: print('DEBUG: No nickname found in '+profileHandle) return None if not searchDomain: if debug: print('DEBUG: No domain found in '+profileHandle) return None searchDomainFull=searchDomain if searchPort: if searchPort!=80 and searchPort!=443: searchDomainFull=searchDomain+':'+str(searchPort) profileStr='' with open(baseDir+'/epicyon-profile.css', 'r') as cssFile: wf = webfingerHandle(session,searchNickname+'@'+searchDomain,httpPrefix,wfRequest) if not wf: if debug: print('DEBUG: Unable to webfinger '+searchNickname+'@'+searchDomain) return None asHeader = {'Accept': 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'} personUrl = getUserUrl(wf) profileJson = getJson(session,personUrl,asHeader,None) if not profileJson: if debug: print('DEBUG: No actor returned from '+personUrl) return None avatarUrl='' if profileJson.get('icon'): if profileJson['icon'].get('url'): avatarUrl=profileJson['icon']['url'] preferredName=searchNickname if profileJson.get('preferredUsername'): preferredName=profileJson['preferredUsername'] profileDescription='' if profileJson.get('summary'): profileDescription=profileJson['summary'] outboxUrl=None if not profileJson.get('outbox'): if debug: pprint(profileJson) print('DEBUG: No outbox found') return None outboxUrl=profileJson['outbox'] profileBackgroundImage='' if profileJson.get('image'): if profileJson['image'].get('url'): profileBackgroundImage=profileJson['image']['url'] profileStyle = cssFile.read().replace('image.png',profileBackgroundImage) # url to return to backUrl=path if not backUrl.endswith('/inbox'): backUrl+='/inbox' profileStr= \ '
' \ '
' \ ' '+searchNickname+'@'+searchDomainFull+'' \ '

'+preferredName+'

' \ '

@'+searchNickname+'@'+searchDomainFull+'

' \ '

'+profileDescription+'

'+ \ '
' \ '
'+ \ '
\n' \ '
' \ '
' \ ' ' \ ' ' \ ' ' \ '
' \ '
' \ '
' profileStr+='' result = [] i = 0 for item in parseUserFeed(session,outboxUrl,asHeader): if not item.get('type'): continue if item['type']!='Create' and item['type']!='Announce': continue if not item.get('object'): continue profileStr+= \ individualPostAsHtml(baseDir, \ session,wfRequest,personCache, \ nickname,domain,port, \ item,avatarUrl,False,False) i+=1 if i>=20: break return htmlHeader(profileStyle)+profileStr+htmlFooter()