epicyon/theme.py

814 lines
28 KiB
Python

__filename__ = "theme.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
import os
from utils import loadJson
from utils import saveJson
from utils import getImageExtensions
from utils import copytree
from shutil import copyfile
from shutil import make_archive
from shutil import unpack_archive
from shutil import rmtree
from content import dangerousCSS
def importTheme(baseDir: str, filename: str) -> bool:
"""Imports a theme
"""
if not os.path.isfile(filename):
return False
tempThemeDir = baseDir + '/imports/files'
if os.path.isdir(tempThemeDir):
rmtree(tempThemeDir)
os.mkdir(tempThemeDir)
unpack_archive(filename, tempThemeDir, 'zip')
essentialThemeFiles = ('name.txt', 'theme.json')
for themeFile in essentialThemeFiles:
if not os.path.isfile(tempThemeDir + '/' + themeFile):
print('WARN: ' + themeFile +
' missing from imported theme')
return False
newThemeName = None
with open(tempThemeDir + '/name.txt', 'r') as fp:
newThemeName = fp.read().replace('\n', '').replace('\r', '')
if len(newThemeName) > 20:
print('WARN: Imported theme name is too long')
return False
if len(newThemeName) < 2:
print('WARN: Imported theme name is too short')
return False
newThemeName = newThemeName.lower()
forbiddenChars = (
' ', ';', '/', '\\', '?', '!', '#', '@',
':', '%', '&', '"', '+', '<', '>', '$'
)
for ch in forbiddenChars:
if ch in newThemeName:
print('WARN: theme name contains forbidden character')
return False
if not newThemeName:
return False
# if the theme name in the default themes list?
defaultThemesFilename = baseDir + '/defaultthemes.txt'
if os.path.isfile(defaultThemesFilename):
if newThemeName.title() + '\n' in open(defaultThemesFilename).read():
newThemeName = newThemeName + '2'
themeDir = baseDir + '/theme/' + newThemeName
if not os.path.isdir(themeDir):
os.mkdir(themeDir)
copytree(tempThemeDir, themeDir)
if os.path.isdir(tempThemeDir):
rmtree(tempThemeDir)
return os.path.isfile(themeDir + '/theme.json')
def exportTheme(baseDir: str, theme: str) -> bool:
"""Exports a theme as a zip file
"""
themeDir = baseDir + '/theme/' + theme
if not os.path.isfile(themeDir + '/theme.json'):
return False
if not os.path.isdir(baseDir + '/exports'):
os.mkdir(baseDir + '/exports')
exportFilename = baseDir + '/exports/' + theme + '.zip'
if os.path.isfile(exportFilename):
os.remove(exportFilename)
try:
make_archive(baseDir + '/exports/' + theme, 'zip', themeDir)
except BaseException:
pass
return os.path.isfile(exportFilename)
def _getThemeFiles() -> []:
"""Gets the list of theme style sheets
"""
return ('epicyon.css', 'login.css', 'follow.css',
'suspended.css', 'calendar.css', 'blog.css',
'options.css', 'search.css', 'links.css',
'welcome.css')
def isNewsThemeName(baseDir: str, themeName: str) -> bool:
"""Returns true if the given theme is a news instance
"""
themeDir = baseDir + '/theme/' + themeName
if os.path.isfile(themeDir + '/is_news_instance'):
return True
return False
def getThemesList(baseDir: str) -> []:
"""Returns the list of available themes
Note that these should be capitalized, since they're
also used to create the web interface dropdown list
and to lookup function names
"""
themes = []
for subdir, dirs, files in os.walk(baseDir + '/theme'):
for themeName in dirs:
if '~' not in themeName and \
themeName != 'icons' and themeName != 'fonts':
themes.append(themeName.title())
break
themes.sort()
print('Themes available: ' + str(themes))
return themes
def _copyThemeHelpFiles(baseDir: str, themeName: str,
systemLanguage: str) -> None:
"""Copies any theme specific help files from the welcome subdirectory
"""
if not systemLanguage:
systemLanguage = 'en'
themeDir = baseDir + '/theme/' + themeName + '/welcome'
if not os.path.isdir(themeDir):
themeDir = baseDir + '/defaultwelcome'
for subdir, dirs, files in os.walk(themeDir):
for helpMarkdownFile in files:
if not helpMarkdownFile.endswith('_' + systemLanguage + '.md'):
continue
destHelpMarkdownFile = \
helpMarkdownFile.replace('_' + systemLanguage + '.md', '.md')
if destHelpMarkdownFile == 'profile.md' or \
destHelpMarkdownFile == 'final.md':
destHelpMarkdownFile = 'welcome_' + destHelpMarkdownFile
if os.path.isdir(baseDir + '/accounts'):
copyfile(themeDir + '/' + helpMarkdownFile,
baseDir + '/accounts/' + destHelpMarkdownFile)
break
def _setThemeInConfig(baseDir: str, name: str) -> bool:
"""Sets the theme with the given name within config.json
"""
configFilename = baseDir + '/config.json'
if not os.path.isfile(configFilename):
return False
configJson = loadJson(configFilename, 0)
if not configJson:
return False
configJson['theme'] = name
return saveJson(configJson, configFilename)
def _setNewswirePublishAsIcon(baseDir: str, useIcon: bool) -> bool:
"""Shows the newswire publish action as an icon or a button
"""
configFilename = baseDir + '/config.json'
if not os.path.isfile(configFilename):
return False
configJson = loadJson(configFilename, 0)
if not configJson:
return False
configJson['showPublishAsIcon'] = useIcon
return saveJson(configJson, configFilename)
def _setIconsAsButtons(baseDir: str, useButtons: bool) -> bool:
"""Whether to show icons in the header (inbox, outbox, etc)
as buttons
"""
configFilename = baseDir + '/config.json'
if not os.path.isfile(configFilename):
return False
configJson = loadJson(configFilename, 0)
if not configJson:
return False
configJson['iconsAsButtons'] = useButtons
return saveJson(configJson, configFilename)
def _setRssIconAtTop(baseDir: str, atTop: bool) -> bool:
"""Whether to show RSS icon at the top of the timeline
"""
configFilename = baseDir + '/config.json'
if not os.path.isfile(configFilename):
return False
configJson = loadJson(configFilename, 0)
if not configJson:
return False
configJson['rssIconAtTop'] = atTop
return saveJson(configJson, configFilename)
def _setPublishButtonAtTop(baseDir: str, atTop: bool) -> bool:
"""Whether to show the publish button above the title image
in the newswire column
"""
configFilename = baseDir + '/config.json'
if not os.path.isfile(configFilename):
return False
configJson = loadJson(configFilename, 0)
if not configJson:
return False
configJson['publishButtonAtTop'] = atTop
return saveJson(configJson, configFilename)
def _setFullWidthTimelineButtonHeader(baseDir: str, fullWidth: bool) -> bool:
"""Shows the timeline button header containing inbox, outbox,
calendar, etc as full width
"""
configFilename = baseDir + '/config.json'
if not os.path.isfile(configFilename):
return False
configJson = loadJson(configFilename, 0)
if not configJson:
return False
configJson['fullWidthTimelineButtonHeader'] = fullWidth
return saveJson(configJson, configFilename)
def getTheme(baseDir: str) -> str:
"""Gets the current theme name from config.json
"""
configFilename = baseDir + '/config.json'
if os.path.isfile(configFilename):
configJson = loadJson(configFilename, 0)
if configJson:
if configJson.get('theme'):
return configJson['theme']
return 'default'
def _removeTheme(baseDir: str):
"""Removes the current theme style sheets
"""
themeFiles = _getThemeFiles()
for filename in themeFiles:
if os.path.isfile(baseDir + '/' + filename):
os.remove(baseDir + '/' + filename)
def setCSSparam(css: str, param: str, value: str) -> str:
"""Sets a CSS parameter to a given value
"""
# is this just a simple string replacement?
if ';' in param:
return css.replace(param, value)
# color replacement
if param.startswith('rgba('):
return css.replace(param, value)
# if the parameter begins with * then don't prepend --
onceOnly = False
if param.startswith('*'):
if param.startswith('**'):
onceOnly = True
searchStr = param.replace('**', '') + ':'
else:
searchStr = param.replace('*', '') + ':'
else:
searchStr = '--' + param + ':'
if searchStr not in css:
return css
if onceOnly:
s = css.split(searchStr, 1)
else:
s = css.split(searchStr)
newcss = ''
for sectionStr in s:
# handle font-family which is a variable
nextSection = sectionStr
if ';' in nextSection:
nextSection = nextSection.split(';')[0] + ';'
if searchStr == 'font-family:' and "var(--" in nextSection:
newcss += searchStr + ' ' + sectionStr
continue
if not newcss:
if sectionStr:
newcss = sectionStr
else:
newcss = ' '
else:
if ';' in sectionStr:
newcss += \
searchStr + ' ' + value + ';' + sectionStr.split(';', 1)[1]
else:
newcss += searchStr + ' ' + sectionStr
return newcss.strip()
def _setThemeFromDict(baseDir: str, name: str,
themeParams: {}, bgParams: {},
allowLocalNetworkAccess: bool) -> None:
"""Uses a dictionary to set a theme
"""
if name:
_setThemeInConfig(baseDir, name)
themeFiles = _getThemeFiles()
for filename in themeFiles:
# check for custom css within the theme directory
templateFilename = baseDir + '/theme/' + name + '/epicyon-' + filename
if filename == 'epicyon.css':
templateFilename = \
baseDir + '/theme/' + name + '/epicyon-profile.css'
# Ensure that any custom CSS is mostly harmless.
# If not then just use the defaults
if dangerousCSS(templateFilename, allowLocalNetworkAccess) or \
not os.path.isfile(templateFilename):
# use default css
templateFilename = baseDir + '/epicyon-' + filename
if filename == 'epicyon.css':
templateFilename = baseDir + '/epicyon-profile.css'
if not os.path.isfile(templateFilename):
continue
with open(templateFilename, 'r') as cssfile:
css = cssfile.read()
for paramName, paramValue in themeParams.items():
if paramName == 'newswire-publish-icon':
if paramValue.lower() == 'true':
_setNewswirePublishAsIcon(baseDir, True)
else:
_setNewswirePublishAsIcon(baseDir, False)
continue
elif paramName == 'full-width-timeline-buttons':
if paramValue.lower() == 'true':
_setFullWidthTimelineButtonHeader(baseDir, True)
else:
_setFullWidthTimelineButtonHeader(baseDir, False)
continue
elif paramName == 'icons-as-buttons':
if paramValue.lower() == 'true':
_setIconsAsButtons(baseDir, True)
else:
_setIconsAsButtons(baseDir, False)
continue
elif paramName == 'rss-icon-at-top':
if paramValue.lower() == 'true':
_setRssIconAtTop(baseDir, True)
else:
_setRssIconAtTop(baseDir, False)
continue
elif paramName == 'publish-button-at-top':
if paramValue.lower() == 'true':
_setPublishButtonAtTop(baseDir, True)
else:
_setPublishButtonAtTop(baseDir, False)
continue
css = setCSSparam(css, paramName, paramValue)
filename = baseDir + '/' + filename
with open(filename, 'w+') as cssfile:
cssfile.write(css)
if bgParams.get('login'):
_setBackgroundFormat(baseDir, name, 'login', bgParams['login'])
if bgParams.get('follow'):
_setBackgroundFormat(baseDir, name, 'follow', bgParams['follow'])
if bgParams.get('options'):
_setBackgroundFormat(baseDir, name, 'options', bgParams['options'])
if bgParams.get('search'):
_setBackgroundFormat(baseDir, name, 'search', bgParams['search'])
if bgParams.get('welcome'):
_setBackgroundFormat(baseDir, name, 'welcome', bgParams['welcome'])
def _setBackgroundFormat(baseDir: str, name: str,
backgroundType: str, extension: str) -> None:
"""Sets the background file extension
"""
if extension == 'jpg':
return
cssFilename = baseDir + '/' + backgroundType + '.css'
if not os.path.isfile(cssFilename):
return
with open(cssFilename, 'r') as cssfile:
css = cssfile.read()
css = css.replace('background.jpg', 'background.' + extension)
with open(cssFilename, 'w+') as cssfile2:
cssfile2.write(css)
def enableGrayscale(baseDir: str) -> None:
"""Enables grayscale for the current theme
"""
themeFiles = _getThemeFiles()
for filename in themeFiles:
templateFilename = baseDir + '/' + filename
if not os.path.isfile(templateFilename):
continue
with open(templateFilename, 'r') as cssfile:
css = cssfile.read()
if 'grayscale' not in css:
css = \
css.replace('body, html {',
'body, html {\n filter: grayscale(100%);')
filename = baseDir + '/' + filename
with open(filename, 'w+') as cssfile:
cssfile.write(css)
grayscaleFilename = baseDir + '/accounts/.grayscale'
if not os.path.isfile(grayscaleFilename):
with open(grayscaleFilename, 'w+') as grayfile:
grayfile.write(' ')
def disableGrayscale(baseDir: str) -> None:
"""Disables grayscale for the current theme
"""
themeFiles = _getThemeFiles()
for filename in themeFiles:
templateFilename = baseDir + '/' + filename
if not os.path.isfile(templateFilename):
continue
with open(templateFilename, 'r') as cssfile:
css = cssfile.read()
if 'grayscale' in css:
css = \
css.replace('\n filter: grayscale(100%);', '')
filename = baseDir + '/' + filename
with open(filename, 'w+') as cssfile:
cssfile.write(css)
grayscaleFilename = baseDir + '/accounts/.grayscale'
if os.path.isfile(grayscaleFilename):
os.remove(grayscaleFilename)
def _setCustomFont(baseDir: str):
"""Uses a dictionary to set a theme
"""
customFontExt = None
customFontType = None
fontExtension = {
'woff': 'woff',
'woff2': 'woff2',
'otf': 'opentype',
'ttf': 'truetype'
}
for ext, extType in fontExtension.items():
filename = baseDir + '/fonts/custom.' + ext
if os.path.isfile(filename):
customFontExt = ext
customFontType = extType
if not customFontExt:
return
themeFiles = _getThemeFiles()
for filename in themeFiles:
templateFilename = baseDir + '/' + filename
if not os.path.isfile(templateFilename):
continue
with open(templateFilename, 'r') as cssfile:
css = cssfile.read()
css = \
setCSSparam(css, "*src",
"url('./fonts/custom." +
customFontExt +
"') format('" +
customFontType + "')")
css = setCSSparam(css, "*font-family", "'CustomFont'")
filename = baseDir + '/' + filename
with open(filename, 'w+') as cssfile:
cssfile.write(css)
def _readVariablesFile(baseDir: str, themeName: str,
variablesFile: str,
allowLocalNetworkAccess: bool) -> None:
"""Reads variables from a file in the theme directory
"""
themeParams = loadJson(variablesFile, 0)
if not themeParams:
return
bgParams = {
"login": "jpg",
"follow": "jpg",
"options": "jpg",
"search": "jpg"
}
_setThemeFromDict(baseDir, themeName, themeParams, bgParams,
allowLocalNetworkAccess)
def _setThemeDefault(baseDir: str, allowLocalNetworkAccess: bool):
name = 'default'
_removeTheme(baseDir)
_setThemeInConfig(baseDir, name)
bgParams = {
"login": "jpg",
"follow": "jpg",
"options": "jpg",
"search": "jpg"
}
themeParams = {
"newswire-publish-icon": True,
"full-width-timeline-buttons": False,
"icons-as-buttons": False,
"rss-icon-at-top": True,
"publish-button-at-top": False,
"banner-height": "20vh",
"banner-height-mobile": "10vh",
"search-banner-height-mobile": "15vh"
}
_setThemeFromDict(baseDir, name, themeParams, bgParams,
allowLocalNetworkAccess)
def _setThemeFonts(baseDir: str, themeName: str) -> None:
"""Adds custom theme fonts
"""
themeNameLower = themeName.lower()
fontsDir = baseDir + '/fonts'
themeFontsDir = \
baseDir + '/theme/' + themeNameLower + '/fonts'
if not os.path.isdir(themeFontsDir):
return
for subdir, dirs, files in os.walk(themeFontsDir):
for filename in files:
if filename.endswith('.woff2') or \
filename.endswith('.woff') or \
filename.endswith('.ttf') or \
filename.endswith('.otf'):
destFilename = fontsDir + '/' + filename
if os.path.isfile(destFilename):
# font already exists in the destination location
continue
copyfile(themeFontsDir + '/' + filename,
destFilename)
break
def getTextModeBanner(baseDir: str) -> str:
"""Returns the banner used for shell browsers, like Lynx
"""
textModeBannerFilename = baseDir + '/accounts/banner.txt'
if os.path.isfile(textModeBannerFilename):
with open(textModeBannerFilename, 'r') as fp:
bannerStr = fp.read()
if bannerStr:
return bannerStr.replace('\n', '<br>')
return None
def getTextModeLogo(baseDir: str) -> str:
"""Returns the login screen logo used for shell browsers, like Lynx
"""
textModeLogoFilename = baseDir + '/accounts/logo.txt'
if not os.path.isfile(textModeLogoFilename):
textModeLogoFilename = baseDir + '/img/logo.txt'
with open(textModeLogoFilename, 'r') as fp:
logoStr = fp.read()
if logoStr:
return logoStr.replace('\n', '<br>')
return None
def _setTextModeTheme(baseDir: str, name: str) -> None:
# set the text mode logo which appears on the login screen
# in browsers such as Lynx
textModeLogoFilename = \
baseDir + '/theme/' + name + '/logo.txt'
if os.path.isfile(textModeLogoFilename):
try:
copyfile(textModeLogoFilename,
baseDir + '/accounts/logo.txt')
except BaseException:
pass
else:
try:
copyfile(baseDir + '/img/logo.txt',
baseDir + '/accounts/logo.txt')
except BaseException:
pass
# set the text mode banner which appears in browsers such as Lynx
textModeBannerFilename = \
baseDir + '/theme/' + name + '/banner.txt'
if os.path.isfile(baseDir + '/accounts/banner.txt'):
os.remove(baseDir + '/accounts/banner.txt')
if os.path.isfile(textModeBannerFilename):
try:
copyfile(textModeBannerFilename,
baseDir + '/accounts/banner.txt')
except BaseException:
pass
def _setThemeImages(baseDir: str, name: str) -> None:
"""Changes the profile background image
and banner to the defaults
"""
themeNameLower = name.lower()
profileImageFilename = \
baseDir + '/theme/' + themeNameLower + '/image.png'
bannerFilename = \
baseDir + '/theme/' + themeNameLower + '/banner.png'
searchBannerFilename = \
baseDir + '/theme/' + themeNameLower + '/search_banner.png'
leftColImageFilename = \
baseDir + '/theme/' + themeNameLower + '/left_col_image.png'
rightColImageFilename = \
baseDir + '/theme/' + themeNameLower + '/right_col_image.png'
_setTextModeTheme(baseDir, themeNameLower)
backgroundNames = ('login', 'shares', 'delete', 'follow',
'options', 'block', 'search', 'calendar',
'welcome')
extensions = getImageExtensions()
for subdir, dirs, files in os.walk(baseDir + '/accounts'):
for acct in dirs:
if '@' not in acct:
continue
if acct.startswith('inbox@'):
continue
elif acct.startswith('news@'):
continue
accountDir = \
os.path.join(baseDir + '/accounts', acct)
for backgroundType in backgroundNames:
for ext in extensions:
if themeNameLower == 'default':
backgroundImageFilename = \
baseDir + '/theme/default/' + \
backgroundType + '_background.' + ext
else:
backgroundImageFilename = \
baseDir + '/theme/' + themeNameLower + '/' + \
backgroundType + '_background' + '.' + ext
if os.path.isfile(backgroundImageFilename):
try:
copyfile(backgroundImageFilename,
baseDir + '/accounts/' +
backgroundType + '-background.' + ext)
continue
except BaseException:
pass
# background image was not found
# so remove any existing file
if os.path.isfile(baseDir + '/accounts/' +
backgroundType + '-background.' + ext):
try:
os.remove(baseDir + '/accounts/' +
backgroundType + '-background.' + ext)
except BaseException:
pass
if os.path.isfile(profileImageFilename) and \
os.path.isfile(bannerFilename):
try:
copyfile(profileImageFilename,
accountDir + '/image.png')
except BaseException:
pass
try:
copyfile(bannerFilename,
accountDir + '/banner.png')
except BaseException:
pass
try:
if os.path.isfile(searchBannerFilename):
copyfile(searchBannerFilename,
accountDir + '/search_banner.png')
except BaseException:
pass
try:
if os.path.isfile(leftColImageFilename):
copyfile(leftColImageFilename,
accountDir + '/left_col_image.png')
else:
if os.path.isfile(accountDir +
'/left_col_image.png'):
os.remove(accountDir + '/left_col_image.png')
except BaseException:
pass
try:
if os.path.isfile(rightColImageFilename):
copyfile(rightColImageFilename,
accountDir + '/right_col_image.png')
else:
if os.path.isfile(accountDir +
'/right_col_image.png'):
os.remove(accountDir + '/right_col_image.png')
except BaseException:
pass
break
def setNewsAvatar(baseDir: str, name: str,
httpPrefix: str,
domain: str, domainFull: str) -> None:
"""Sets the avatar for the news account
"""
nickname = 'news'
newFilename = baseDir + '/theme/' + name + '/icons/avatar_news.png'
if not os.path.isfile(newFilename):
newFilename = baseDir + '/theme/default/icons/avatar_news.png'
if not os.path.isfile(newFilename):
return
avatarFilename = \
httpPrefix + '://' + domainFull + '/users/' + nickname + '.png'
avatarFilename = avatarFilename.replace('/', '-')
filename = baseDir + '/cache/avatars/' + avatarFilename
if os.path.isfile(filename):
os.remove(filename)
if os.path.isdir(baseDir + '/cache/avatars'):
copyfile(newFilename, filename)
copyfile(newFilename,
baseDir + '/accounts/' +
nickname + '@' + domain + '/avatar.png')
def _setClearCacheFlag(baseDir: str) -> None:
"""Sets a flag which can be used by an external system
(eg. a script in a cron job) to clear the browser cache
"""
if not os.path.isdir(baseDir + '/accounts'):
return
flagFilename = baseDir + '/accounts/.clear_cache'
with open(flagFilename, 'w+') as flagFile:
flagFile.write('\n')
def setTheme(baseDir: str, name: str, domain: str,
allowLocalNetworkAccess: bool, systemLanguage: str) -> bool:
"""Sets the theme with the given name as the current theme
"""
result = False
prevThemeName = getTheme(baseDir)
_removeTheme(baseDir)
themes = getThemesList(baseDir)
for themeName in themes:
themeNameLower = themeName.lower()
if name == themeNameLower:
try:
globals()['setTheme' + themeName](baseDir,
allowLocalNetworkAccess)
except BaseException:
pass
if prevThemeName:
if prevThemeName.lower() != themeNameLower:
# change the banner and profile image
# to the default for the theme
_setThemeImages(baseDir, name)
_setThemeFonts(baseDir, name)
result = True
if not result:
# default
_setThemeDefault(baseDir, allowLocalNetworkAccess)
result = True
variablesFile = baseDir + '/theme/' + name + '/theme.json'
if os.path.isfile(variablesFile):
_readVariablesFile(baseDir, name, variablesFile,
allowLocalNetworkAccess)
_setCustomFont(baseDir)
# set the news avatar
newsAvatarThemeFilename = \
baseDir + '/theme/' + name + '/icons/avatar_news.png'
if os.path.isdir(baseDir + '/accounts/news@' + domain):
if os.path.isfile(newsAvatarThemeFilename):
newsAvatarFilename = \
baseDir + '/accounts/news@' + domain + '/avatar.png'
copyfile(newsAvatarThemeFilename, newsAvatarFilename)
grayscaleFilename = baseDir + '/accounts/.grayscale'
if os.path.isfile(grayscaleFilename):
enableGrayscale(baseDir)
else:
disableGrayscale(baseDir)
_copyThemeHelpFiles(baseDir, name, systemLanguage)
_setThemeInConfig(baseDir, name)
_setClearCacheFlag(baseDir)
return result
def updateDefaultThemesList(baseDir: str) -> None:
"""Recreates the list of default themes
"""
themeNames = getThemesList(baseDir)
defaultThemesFilename = baseDir + '/defaultthemes.txt'
with open(defaultThemesFilename, 'w+') as defaultThemesFile:
for name in themeNames:
defaultThemesFile.write(name + '\n')