diff --git a/bin/nanopub-1.34-jar-with-dependencies.jar b/bin/nanopub-1.36-jar-with-dependencies.jar similarity index 85% rename from bin/nanopub-1.34-jar-with-dependencies.jar rename to bin/nanopub-1.36-jar-with-dependencies.jar index 1f48e07..f483cc0 100644 Binary files a/bin/nanopub-1.34-jar-with-dependencies.jar and b/bin/nanopub-1.36-jar-with-dependencies.jar differ diff --git a/config.yml b/config.yml index efb75f5..c83540c 100644 --- a/config.yml +++ b/config.yml @@ -34,6 +34,19 @@ security: tokens: - ... +mail: + enabled: false + name: + email: + host: + port: + security: + authEnabled: + username: + password: + recipients: + - + #logging: # level: WARNING # format: ... diff --git a/nanopub_submitter/api.py b/nanopub_submitter/api.py index 57f07d9..4463117 100644 --- a/nanopub_submitter/api.py +++ b/nanopub_submitter/api.py @@ -10,6 +10,7 @@ from nanopub_submitter.consts import NICE_NAME, VERSION, BUILD_INFO,\ ENV_CONFIG, DEFAULT_CONFIG, DEFAULT_ENCODING from nanopub_submitter.logger import LOG, init_default_logging, init_config_logging +from nanopub_submitter.mailer import Mailer from nanopub_submitter.nanopub import process, NanopubProcessingError app = fastapi.FastAPI( @@ -86,7 +87,9 @@ async def submit_nanopub(request: fastapi.Request): status_code=fastapi.status.HTTP_500_INTERNAL_SERVER_ERROR, content='Failed to process the nanopublication', ) - # (4) Return + # (4) Mail + Mailer.get().notice(nanopub_uri=result.location) + # (5) Return return fastapi.responses.Response( status_code=fastapi.status.HTTP_201_CREATED, headers={ @@ -105,6 +108,7 @@ async def app_init(): with pathlib.Path(config_file).open() as fp: cfg = cfg_parser.parse_file(fp=fp) init_config_logging(config=cfg) + Mailer.init(config=cfg) except Exception as e: LOG.warn(f'Failed to load config: {config_file}') LOG.debug(str(e)) diff --git a/nanopub_submitter/config.py b/nanopub_submitter/config.py index 60aac85..3e66d4a 100644 --- a/nanopub_submitter/config.py +++ b/nanopub_submitter/config.py @@ -14,7 +14,7 @@ def __init__(self, missing: List[str]): class NanopubConfig: def __init__(self, servers: List[str], client_exec: str, - strategy: str, strategy_number: int, + strategy: str, strategy_number: int, uri_replace: str, client_timeout: int, workdir: str, sign_key_type: str, sign_nanopub: bool, sign_private_key: Optional[str]): self.servers = servers @@ -26,6 +26,7 @@ def __init__(self, servers: List[str], client_exec: str, self.sign_key_type = sign_key_type self.sign_private_key = sign_private_key self.workdir = pathlib.Path(workdir) + self.uri_replace = uri_replace @property def target_servers(self) -> list[str]: @@ -66,14 +67,33 @@ def __init__(self, level, message_format: str): self.format = message_format +class MailConfig: + + def __init__(self, enabled: bool, name: str, email: str, + host: str, port: int, security: str, auth: bool, + username: str, password: str, recipients: list[str]): + self.enabled = enabled + self.name = name + self.email = email + self.host = host + self.port = port + self.security = security.lower() + self.auth = auth + self.username = username + self.password = password + self.recipients = recipients + + class SubmitterConfig: def __init__(self, nanopub: NanopubConfig, security: SecurityConfig, - triple_store: TripleStoreConfig, logging: LoggingConfig): + triple_store: TripleStoreConfig, logging: LoggingConfig, + mail: MailConfig): self.nanopub = nanopub self.security = security self.triple_store = triple_store self.logging = logging + self.mail = mail class SubmitterConfigParser: @@ -89,6 +109,7 @@ class SubmitterConfigParser: 'sign_key_type': 'DSA', 'sign_private_key': '', 'workdir': '/app/workdir', + 'uri_replace': None, }, 'triple_store': { 'enabled': False, @@ -114,6 +135,18 @@ class SubmitterConfigParser: 'level': 'INFO', 'format': '%(asctime)s | %(levelname)s | %(module)s: %(message)s', }, + 'mail': { + 'enabled': False, + 'name': 'Nanopub Submitter', + 'email': '', + 'host': '', + 'port': 25, + 'security': 'plain', + 'authEnabled': False, + 'username': '', + 'password': '', + 'recipients': [], + }, } REQUIRED = [] # type: List[List[str]] @@ -163,6 +196,7 @@ def _nanopub(self): sign_key_type=self.get_or_default('nanopub', 'sign_key_type'), sign_private_key=self.get_or_default('nanopub', 'sign_private_key'), workdir=self.get_or_default('nanopub', 'workdir'), + uri_replace=self.get_or_default('nanopub', 'uri_replace'), ) @property @@ -194,6 +228,21 @@ def _triple_store(self): strategy=self.get_or_default('triple_store', 'strategy'), ) + @property + def _mail(self): + return MailConfig( + enabled=self.get_or_default('mail', 'enabled'), + name=self.get_or_default('mail', 'name'), + email=self.get_or_default('mail', 'email'), + host=self.get_or_default('mail', 'host'), + port=self.get_or_default('mail', 'port'), + security=self.get_or_default('mail', 'security'), + auth=self.get_or_default('mail', 'authEnabled'), + username=self.get_or_default('mail', 'username'), + password=self.get_or_default('mail', 'password'), + recipients=self.get_or_default('mail', 'recipients'), + ) + def parse_file(self, fp) -> SubmitterConfig: self.cfg = yaml.full_load(fp) self.validate() @@ -206,6 +255,7 @@ def config(self) -> SubmitterConfig: security=self._security, logging=self._logging, triple_store=self._triple_store, + mail=self._mail, ) diff --git a/nanopub_submitter/consts.py b/nanopub_submitter/consts.py index e8ac82d..97100c9 100644 --- a/nanopub_submitter/consts.py +++ b/nanopub_submitter/consts.py @@ -1,6 +1,6 @@ PACKAGE_NAME = 'nanopub_submitter' NICE_NAME = 'DSW Nanopublication Submission Service' -PACKAGE_VERSION = '1.0.0' +PACKAGE_VERSION = '1.1.0' ENV_CONFIG = 'SUBMISSION_CONFIG' LOGGER_NAME = 'DSW_SUBMITTER' diff --git a/nanopub_submitter/mailer.py b/nanopub_submitter/mailer.py new file mode 100644 index 0000000..de1c72e --- /dev/null +++ b/nanopub_submitter/mailer.py @@ -0,0 +1,102 @@ +import email +import smtplib +import ssl + +from nanopub_submitter.config import SubmitterConfig +from nanopub_submitter.logger import LOG + + +class Mailer: + _instance = None + + def __init__(self): + self.cfg = None + + @classmethod + def init(cls, config: SubmitterConfig): + cls.get().cfg = config + + @classmethod + def get(cls): + if cls._instance is None: + cls._instance = Mailer() + return cls._instance + + def _msg_text(self, nanopub_uri: str): + return f"Hello,\n" \ + f"new nanopublication has been submitted:\n" \ + f"{nanopub_uri}\n" \ + f"____________________________________________________\n" \ + f"Have a nice day!\n" \ + f"{self.cfg.mail.name}\n" + + def notice(self, nanopub_uri: str): + if not self.cfg.mail.enabled: + LOG.debug(f'Notification for {nanopub_uri} skipped' + f'(mail disabled)') + return + if len(self.cfg.mail.recipients) < 1: + LOG.debug(f'Notification for {nanopub_uri} skipped' + f'(no recipients defined)') + return + LOG.info(f'Sending notification for {nanopub_uri}') + + msg = email.message.Message() + msg['From'] = self.cfg.mail.email + msg['To'] = ', '.join(self.cfg.mail.recipients) + msg['Subject'] = f'[{self.cfg.mail.name}] New nanopublication' + msg.add_header('Content-Type', 'text/plain') + msg.set_payload(self._msg_text(nanopub_uri)) + try: + result = self._send(msg) + LOG.debug(f'Email result: {result}') + except Exception as e: + LOG.warn(f'Failed to send notification: {str(e)}') + + def _send(self, message: email.message.Message): + if self.cfg.mail.security == 'ssl': + return self._send_smtp_ssl( + message=message, + ) + return self._send_smtp( + message=message, + use_tls=self.cfg.mail.security == 'starttls', + ) + + def _send_smtp_ssl(self, message: email.message.Message): + context = ssl.create_default_context() + with smtplib.SMTP_SSL( + host=self.cfg.mail.host, + port=self.cfg.mail.port, + context=context, + ) as server: + if self.cfg.mail.auth: + server.login( + user=self.cfg.mail.username, + password=self.cfg.mail.password, + ) + return server.send_message( + msg=message, + from_addr=self.cfg.mail.email, + to_addrs=self.cfg.mail.recipients, + ) + + def _send_smtp(self, message: email.message.Message, + use_tls: bool): + context = ssl.create_default_context() + with smtplib.SMTP( + host=self.cfg.mail.host, + port=self.cfg.mail.port, + ) as server: + if use_tls: + server.starttls(context=context) + if self.cfg.mail.auth: + server.login( + user=self.cfg.mail.username, + password=self.cfg.mail.password, + ) + return server.send_message( + msg=message, + from_addr=self.cfg.mail.email, + to_addrs=self.cfg.mail.recipients, + ) diff --git a/nanopub_submitter/nanopub.py b/nanopub_submitter/nanopub.py index 5b02ad8..53b2c03 100644 --- a/nanopub_submitter/nanopub.py +++ b/nanopub_submitter/nanopub.py @@ -83,25 +83,44 @@ def error(self, message: str): LOG.error(f'{self._pre} {message}') -def _publish_nanopub(nanopub: str, ctx: NanopubProcessingContext) -> list[str]: +def _split_nanopubs(nanopub_bundle: str) -> list[str]: + lines = nanopub_bundle.splitlines() + nanopubs = list() # type: list[str] + act_lines = list() # type: list[str] + for line in lines: + if line.startswith('@prefix this:') and len(act_lines) > 0: + nanopubs.append('\n'.join(act_lines) + '\n') + act_lines.clear() + act_lines.append(line) + if len(act_lines) > 0: + nanopubs.append('\n'.join(act_lines) + '\n') + return nanopubs + + +def _publish_nanopub(nanopub_bundle: str, ctx: NanopubProcessingContext) -> list[str]: success = [] + nanopubs = _split_nanopubs(nanopub_bundle) for server in ctx.cfg.nanopub.target_servers: ctx.debug(f'Submitting to: {server}') - r = requests.post( - url=server, - data=nanopub, - headers={ - 'Content-Type': f'application/trig; charset={DEFAULT_ENCODING}', - 'User-Agent': f'{PACKAGE_NAME}/{PACKAGE_VERSION}', - } - ) - if r.ok: - ctx.warn(f'Nanopub published via {server}') + ok = True + for nanopub in nanopubs: + r = requests.post( + url=server, + data=nanopub, + headers={ + 'Content-Type': f'application/trig; charset={DEFAULT_ENCODING}', + 'User-Agent': f'{PACKAGE_NAME}/{PACKAGE_VERSION}', + } + ) + if not r.ok: + ok = False + ctx.warn(f'Failed to publish nanopub via {server}') + ctx.debug(f'status={r.status_code}') + ctx.debug(r.text) + break + if ok: + ctx.info(f'Nanopub published via {server}') success.append(server) - else: - ctx.warn(f'Failed to publish nanopub via {server}') - ctx.debug(f'status={r.status_code}') - ctx.debug(r.text) return success @@ -132,8 +151,9 @@ def _np(*args, ctx: NanopubProcessingContext) -> Tuple[int, str, str]: def _run_np_trusty(ctx: NanopubProcessingContext) -> str: exit_code, stdout, stderr = _np( - 'mktrusty', '-o', ctx.trusty_file, ctx.input_file, - ctx=ctx + 'mktrusty', '-r', + '-o', ctx.trusty_file, ctx.input_file, + ctx=ctx, ) if exit_code != EXIT_SUCCESS: LOG.warn(f'Failed to make TrustyURI ({exit_code}):\n{stdout}\n\n{stderr}') @@ -146,10 +166,11 @@ def _run_np_trusty(ctx: NanopubProcessingContext) -> str: def _run_np_sign(ctx: NanopubProcessingContext) -> str: exit_code, stdout, stderr = _np( - 'sign', '-a', ctx.cfg.nanopub.sign_key_type, + 'sign', '-r', + '-a', ctx.cfg.nanopub.sign_key_type, '-k', ctx.cfg.nanopub.sign_private_key, '-o', ctx.signed_file, ctx.input_file, - ctx=ctx + ctx=ctx, ) if exit_code != EXIT_SUCCESS: LOG.warn(f'Failed to make TrustyURI ({exit_code}):\n{stdout}\n\n{stderr}') @@ -161,13 +182,14 @@ def _run_np_sign(ctx: NanopubProcessingContext) -> str: def _extract_np_uri(nanopub: str) -> Optional[str]: + last_this_prefix = None for line in nanopub.splitlines(): - if '@prefix this:' in line: + if line.startswith('@prefix this:'): try: - return line.split('<', maxsplit=1)[1].split('>', maxsplit=1)[0] + last_this_prefix = line.split('<', maxsplit=1)[1].split('>', maxsplit=1)[0] except Exception: continue - return None + return last_this_prefix def process(cfg: SubmitterConfig, submission_id: str, data: str) -> NanopubSubmissionResult: @@ -190,14 +212,12 @@ def process(cfg: SubmitterConfig, submission_id: str, data: str) -> NanopubSubmi ctx.cleanup() raise NanopubProcessingError(500, 'Failed to store nanopub locally') - ctx.debug('Generating trusty URIs for the nanopub') - result_file = _run_np_trusty(ctx=ctx) - if cfg.nanopub.sign_nanopub: ctx.debug('Signing nanopub with private key') result_file = _run_np_sign(ctx=ctx) else: - ctx.debug('Signing nanopub skipped (disabled by config)') + ctx.debug('Generating trusty URIs for the nanopub') + result_file = _run_np_trusty(ctx=ctx) ctx.debug('Reading final nanopub') result_path = cfg.nanopub.workdir / result_file @@ -214,8 +234,14 @@ def process(cfg: SubmitterConfig, submission_id: str, data: str) -> NanopubSubmi ctx.cleanup() raise NanopubProcessingError(400, 'Failed to get nanopub URI') - ctx.debug('Submitting nanopub to server(s)') - servers = _publish_nanopub(nanopub=nanopub, ctx=ctx) + if cfg.nanopub.uri_replace is not None: + old, new = cfg.nanopub.uri_replace.split('|', maxsplit=1) + new_uri = nanopub_uri.replace(old, new) + LOG.debug(f'Replacing {nanopub_uri} with {new_uri}') + nanopub_uri = new_uri + + ctx.debug('Submitting nanopub(s) to server(s)') + servers = _publish_nanopub(nanopub_bundle=nanopub, ctx=ctx) if len(servers) == 0: ctx.error('Failed to publish nanopub') diff --git a/setup.py b/setup.py index 067e8a8..27c4cb4 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='nanopub_submitter', - version='1.0.0', + version='1.1.0', keywords='dsw submission document nanopublication', description='Submission service for publishing nanopublications from DSW', long_description=long_description,