epicyon/media.py

432 lines
15 KiB
Python

__filename__ = "media.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@libreserver.org"
__status__ = "Production"
__module_group__ = "Timeline"
import os
import time
import datetime
import subprocess
import random
from random import randint
from hashlib import sha1
from auth import createPassword
from utils import get_base_content_from_post
from utils import get_full_domain
from utils import getImageExtensions
from utils import getVideoExtensions
from utils import getAudioExtensions
from utils import getMediaExtensions
from utils import has_object_dict
from utils import acct_dir
from shutil import copyfile
from shutil import rmtree
from shutil import move
from city import spoofGeolocation
def _getBlurHash() -> str:
"""You may laugh, but this is a lot less computationally intensive,
especially on large images, while still providing some visual variety
in the timeline
"""
hashes = [
"UfGuaW01%gRi%MM{azofozo0V@xuozn#ofs.",
"UFD]o8-;9FIU~qD%j[%M-;j[ofWB?bt7IURj",
"UyO|v_1#im=s%y#U%OxDwRt3W9R-ogjHj[WX",
"U96vAQt6H;WBt7ofWBa#MbWBo#j[byaze-oe",
"UJKA.q01M|IV%LM|RjNGIVj[f6oLjrofaeof",
"U9MPjn]?~Cxut~.PS1%1xXIo0fEer_$*^jxG",
"UtLENXWCRjju~qayaeaz00j[ofayIVkCkCfQ",
"UHGbeg-pbzWZ.ANI$wsQ$H-;E9W?0Nx]?FjE",
"UcHU%#4n_ND%?bxatRWBIU%MazxtNaRjs:of",
"ULR:TsWr~6xZofWWf6s-~6oK9eR,oes-WXNJ",
"U77VQB-:MaMx%L%MogRkMwkCxuoIS*WYjEsl",
"U%Nm{8R+%MxuE1t6WBNG-=RjoIt6~Vj]RkR*",
"UCM7u;?boft7oft7ayj[~qt7WBoft7oft7Rj"
]
return random.choice(hashes)
def _replaceSiloDomain(post_json_object: {},
siloDomain: str, replacementDomain: str,
system_language: str) -> None:
"""Replace a silo domain with a replacement domain
"""
if not replacementDomain:
return
if not has_object_dict(post_json_object):
return
if not post_json_object['object'].get('content'):
return
contentStr = get_base_content_from_post(post_json_object, system_language)
if siloDomain not in contentStr:
return
contentStr = contentStr.replace(siloDomain, replacementDomain)
post_json_object['object']['content'] = contentStr
if post_json_object['object'].get('contentMap'):
post_json_object['object']['contentMap'][system_language] = contentStr
def replaceYouTube(post_json_object: {}, replacementDomain: str,
system_language: str) -> None:
"""Replace YouTube with a replacement domain
This denies Google some, but not all, tracking data
"""
_replaceSiloDomain(post_json_object, 'www.youtube.com',
replacementDomain, system_language)
def replaceTwitter(post_json_object: {}, replacementDomain: str,
system_language: str) -> None:
"""Replace Twitter with a replacement domain
This allows you to view twitter posts without having a twitter account
"""
_replaceSiloDomain(post_json_object, 'twitter.com',
replacementDomain, system_language)
def _removeMetaData(imageFilename: str, outputFilename: str) -> None:
"""Attempts to do this with pure python didn't work well,
so better to use a dedicated tool if one is installed
"""
copyfile(imageFilename, outputFilename)
if not os.path.isfile(outputFilename):
print('ERROR: unable to remove metadata from ' + imageFilename)
return
if os.path.isfile('/usr/bin/exiftool'):
print('Removing metadata from ' + outputFilename + ' using exiftool')
os.system('exiftool -all= ' + outputFilename) # nosec
elif os.path.isfile('/usr/bin/mogrify'):
print('Removing metadata from ' + outputFilename + ' using mogrify')
os.system('/usr/bin/mogrify -strip ' + outputFilename) # nosec
def _spoofMetaData(base_dir: str, nickname: str, domain: str,
outputFilename: str, spoofCity: str,
content_license_url: str) -> None:
"""Spoof image metadata using a decoy model for a given city
"""
if not os.path.isfile(outputFilename):
print('ERROR: unable to spoof metadata within ' + outputFilename)
return
# get the random seed used to generate a unique pattern for this account
decoySeedFilename = acct_dir(base_dir, nickname, domain) + '/decoyseed'
decoySeed = 63725
if os.path.isfile(decoySeedFilename):
with open(decoySeedFilename, 'r') as fp:
decoySeed = int(fp.read())
else:
decoySeed = randint(10000, 10000000000000000)
try:
with open(decoySeedFilename, 'w+') as fp:
fp.write(str(decoySeed))
except OSError:
print('EX: unable to write ' + decoySeedFilename)
if os.path.isfile('/usr/bin/exiftool'):
print('Spoofing metadata in ' + outputFilename + ' using exiftool')
curr_timeAdjusted = \
datetime.datetime.utcnow() - \
datetime.timedelta(minutes=randint(2, 120))
published = curr_timeAdjusted.strftime("%Y:%m:%d %H:%M:%S+00:00")
(latitude, longitude, latitudeRef, longitudeRef,
camMake, camModel, camSerialNumber) = \
spoofGeolocation(base_dir, spoofCity, curr_timeAdjusted,
decoySeed, None, None)
if os.system('exiftool -artist=@"' + nickname + '@' + domain + '" ' +
'-Make="' + camMake + '" ' +
'-Model="' + camModel + '" ' +
'-Comment="' + str(camSerialNumber) + '" ' +
'-DateTimeOriginal="' + published + '" ' +
'-FileModifyDate="' + published + '" ' +
'-CreateDate="' + published + '" ' +
'-GPSLongitudeRef=' + longitudeRef + ' ' +
'-GPSAltitude=0 ' +
'-GPSLongitude=' + str(longitude) + ' ' +
'-GPSLatitudeRef=' + latitudeRef + ' ' +
'-GPSLatitude=' + str(latitude) + ' ' +
'-copyright="' + content_license_url + '" ' +
'-Comment="" ' +
outputFilename) != 0: # nosec
print('ERROR: exiftool failed to run')
else:
print('ERROR: exiftool is not installed')
return
def convertImageToLowBandwidth(imageFilename: str) -> None:
"""Converts an image to a low bandwidth version
"""
low_bandwidthFilename = imageFilename + '.low'
if os.path.isfile(low_bandwidthFilename):
try:
os.remove(low_bandwidthFilename)
except OSError:
print('EX: convertImageToLowBandwidth unable to delete ' +
low_bandwidthFilename)
cmd = \
'/usr/bin/convert +noise Multiplicative ' + \
'-evaluate median 10% -dither Floyd-Steinberg ' + \
'-monochrome ' + imageFilename + ' ' + low_bandwidthFilename
print('Low bandwidth image conversion: ' + cmd)
subprocess.call(cmd, shell=True)
# wait for conversion to happen
ctr = 0
while not os.path.isfile(low_bandwidthFilename):
print('Waiting for low bandwidth image conversion ' + str(ctr))
time.sleep(0.2)
ctr += 1
if ctr > 100:
print('WARN: timed out waiting for low bandwidth image conversion')
break
if os.path.isfile(low_bandwidthFilename):
try:
os.remove(imageFilename)
except OSError:
print('EX: convertImageToLowBandwidth unable to delete ' +
imageFilename)
os.rename(low_bandwidthFilename, imageFilename)
if os.path.isfile(imageFilename):
print('Image converted to low bandwidth ' + imageFilename)
else:
print('Low bandwidth converted image not found: ' +
low_bandwidthFilename)
def processMetaData(base_dir: str, nickname: str, domain: str,
imageFilename: str, outputFilename: str,
city: str, content_license_url: str) -> None:
"""Handles image metadata. This tries to spoof the metadata
if possible, but otherwise just removes it
"""
# first remove the metadata
_removeMetaData(imageFilename, outputFilename)
# now add some spoofed data to misdirect surveillance capitalists
_spoofMetaData(base_dir, nickname, domain, outputFilename, city,
content_license_url)
def _isMedia(imageFilename: str) -> bool:
"""Is the given file a media file?
"""
if not os.path.isfile(imageFilename):
print('WARN: Media file does not exist ' + imageFilename)
return False
permittedMedia = getMediaExtensions()
for m in permittedMedia:
if imageFilename.endswith('.' + m):
return True
print('WARN: ' + imageFilename + ' is not a permitted media type')
return False
def createMediaDirs(base_dir: str, mediaPath: str) -> None:
if not os.path.isdir(base_dir + '/media'):
os.mkdir(base_dir + '/media')
if not os.path.isdir(base_dir + '/' + mediaPath):
os.mkdir(base_dir + '/' + mediaPath)
def getMediaPath() -> str:
curr_time = datetime.datetime.utcnow()
weeksSinceEpoch = int((curr_time - datetime.datetime(1970, 1, 1)).days / 7)
return 'media/' + str(weeksSinceEpoch)
def getAttachmentMediaType(filename: str) -> str:
"""Returns the type of media for the given file
image, video or audio
"""
mediaType = None
imageTypes = getImageExtensions()
for mType in imageTypes:
if filename.endswith('.' + mType):
return 'image'
videoTypes = getVideoExtensions()
for mType in videoTypes:
if filename.endswith('.' + mType):
return 'video'
audioTypes = getAudioExtensions()
for mType in audioTypes:
if filename.endswith('.' + mType):
return 'audio'
return mediaType
def _updateEtag(mediaFilename: str) -> None:
""" calculate the etag, which is a sha1 of the data
"""
# only create etags for media
if '/media/' not in mediaFilename:
return
# check that the media exists
if not os.path.isfile(mediaFilename):
return
# read the binary data
data = None
try:
with open(mediaFilename, 'rb') as mediaFile:
data = mediaFile.read()
except OSError:
print('EX: _updateEtag unable to read ' + str(mediaFilename))
if not data:
return
# calculate hash
etag = sha1(data).hexdigest() # nosec
# save the hash
try:
with open(mediaFilename + '.etag', 'w+') as etagFile:
etagFile.write(etag)
except OSError:
print('EX: _updateEtag unable to write ' +
str(mediaFilename) + '.etag')
def attachMedia(base_dir: str, http_prefix: str,
nickname: str, domain: str, port: int,
postJson: {}, imageFilename: str,
mediaType: str, description: str,
city: str, low_bandwidth: bool,
content_license_url: str) -> {}:
"""Attaches media to a json object post
The description can be None
"""
if not _isMedia(imageFilename):
return postJson
fileExtension = None
acceptedTypes = getMediaExtensions()
for mType in acceptedTypes:
if imageFilename.endswith('.' + mType):
if mType == 'jpg':
mType = 'jpeg'
if mType == 'mp3':
mType = 'mpeg'
fileExtension = mType
if not fileExtension:
return postJson
mediaType = mediaType + '/' + fileExtension
print('Attached media type: ' + mediaType)
if fileExtension == 'jpeg':
fileExtension = 'jpg'
if mediaType == 'audio/mpeg':
fileExtension = 'mp3'
domain = get_full_domain(domain, port)
mPath = getMediaPath()
mediaPath = mPath + '/' + createPassword(32) + '.' + fileExtension
if base_dir:
createMediaDirs(base_dir, mPath)
mediaFilename = base_dir + '/' + mediaPath
mediaPath = \
mediaPath.replace('media/', 'system/media_attachments/files/', 1)
attachmentJson = {
'mediaType': mediaType,
'name': description,
'type': 'Document',
'url': http_prefix + '://' + domain + '/' + mediaPath
}
if mediaType.startswith('image/'):
attachmentJson['blurhash'] = _getBlurHash()
# find the dimensions of the image and add them as metadata
attachImageWidth, attachImageHeight = \
getImageDimensions(imageFilename)
if attachImageWidth and attachImageHeight:
attachmentJson['width'] = attachImageWidth
attachmentJson['height'] = attachImageHeight
postJson['attachment'] = [attachmentJson]
if base_dir:
if mediaType.startswith('image/'):
if low_bandwidth:
convertImageToLowBandwidth(imageFilename)
processMetaData(base_dir, nickname, domain,
imageFilename, mediaFilename, city,
content_license_url)
else:
copyfile(imageFilename, mediaFilename)
_updateEtag(mediaFilename)
return postJson
def archiveMedia(base_dir: str, archive_directory: str, maxWeeks: int) -> None:
"""Any media older than the given number of weeks gets archived
"""
if maxWeeks == 0:
return
curr_time = datetime.datetime.utcnow()
weeksSinceEpoch = int((curr_time - datetime.datetime(1970, 1, 1)).days/7)
minWeek = weeksSinceEpoch - maxWeeks
if archive_directory:
if not os.path.isdir(archive_directory):
os.mkdir(archive_directory)
if not os.path.isdir(archive_directory + '/media'):
os.mkdir(archive_directory + '/media')
for subdir, dirs, files in os.walk(base_dir + '/media'):
for weekDir in dirs:
if int(weekDir) < minWeek:
if archive_directory:
move(os.path.join(base_dir + '/media', weekDir),
archive_directory + '/media')
else:
# archive to /dev/null
rmtree(os.path.join(base_dir + '/media', weekDir),
ignore_errors=False, onerror=None)
break
def pathIsVideo(path: str) -> bool:
if path.endswith('.ogv') or \
path.endswith('.mp4'):
return True
return False
def pathIsAudio(path: str) -> bool:
if path.endswith('.ogg') or \
path.endswith('.mp3'):
return True
return False
def getImageDimensions(imageFilename: str) -> (int, int):
"""Returns the dimensions of an image file
"""
try:
result = subprocess.run(['identify', '-format', '"%wx%h"',
imageFilename], stdout=subprocess.PIPE)
except BaseException:
print('EX: getImageDimensions unable to run identify command')
return None, None
if not result:
return None, None
dimensionsStr = result.stdout.decode('utf-8').replace('"', '')
if 'x' not in dimensionsStr:
return None, None
widthStr = dimensionsStr.split('x')[0]
if not widthStr.isdigit():
return None, None
heightStr = dimensionsStr.split('x')[1]
if not heightStr.isdigit():
return None, None
return int(widthStr), int(heightStr)