From 8c08120d543f4eba2b1d1480d6649f13c46e9ef0 Mon Sep 17 00:00:00 2001 From: Thomas <> Date: Mon, 7 Feb 2022 10:37:42 +0100 Subject: feature(ernp): add person into ERnP of mathing ends with no result --- .../auth/eidas/v2/clients/ernp/ErnpRestClient.java | 301 ++++++++++++--------- .../auth/eidas/v2/clients/ernp/IErnpClient.java | 12 + .../auth/eidas/v2/ernp/DummyErnpClient.java | 5 + .../eidas/v2/tasks/CreateNewErnpEntryTask.java | 58 ++-- 4 files changed, 232 insertions(+), 144 deletions(-) (limited to 'eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus') diff --git a/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/clients/ernp/ErnpRestClient.java b/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/clients/ernp/ErnpRestClient.java index 2eaece1d..58b3ca45 100644 --- a/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/clients/ernp/ErnpRestClient.java +++ b/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/clients/ernp/ErnpRestClient.java @@ -41,11 +41,14 @@ import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.SimpleEidasData; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ernp.api.DefaultApi; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ernp.invoker.ApiClient; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ernp.model.AendernResponse; +import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ernp.model.AnlegenResponse; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ernp.model.Eidas; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ernp.model.PartialDate; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ernp.model.Person; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ernp.model.PersonAendern; +import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ernp.model.PersonAnlegen; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ernp.model.PersonSuchen; +import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ernp.model.Personendaten; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ernp.model.SuchEidas; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ernp.model.Suchdaten; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ernp.model.SuchenResponse; @@ -100,7 +103,8 @@ public class ErnpRestClient implements IErnpClient { private static final String PROCESS_KITT_GENERAL = "KITT general-processing"; private static final String PROCESS_KITT_IDENITIES_GET = "KITT get-latest-version"; private static final String PROCESS_KITT_IDENITIES_UPDATE = "KITT update dataset"; - + private static final String PROCESS_ADD_IDENITY = "Add new person"; + private static final String FRIENDLYNAME_HTTP_CLIENT = "ERnP Client"; // HTTP header-names from ERnP response @@ -272,141 +276,41 @@ public class ErnpRestClient implements IErnpClient { } } - private ErnpRegisterResult updatePersonInErnp(Person ernpPersonToKitt, - Collection eidasDocumentToAdd, String citizenCountryCode) - throws ServiceFault { + @Override + public ErnpRegisterResult add(SimpleEidasData eidData) throws EidasSAuthenticationException { // build generic request metadata - final GenericRequestParams generic = buildGenericRequestParameters("stepKittUpdate"); + final GenericRequestParams generic = buildGenericRequestParameters("stepNew"); // build update request - PersonAendern ernpReq = new PersonAendern(); - ernpReq.setBegruendung(PROCESS_KITT_IDENITIES_UPDATE); - - // set reference elements for person update - ernpReq.setEntityId(ernpPersonToKitt.getEntityId()); - ernpReq.setVersion(ernpPersonToKitt.getVersion()); - - // add new eIDAS attributes - eidasDocumentToAdd.stream().forEach(el -> ernpReq.getAnlegen().addEidasItem(el)); - - // TODO: should we update MDS also in that step? - + PersonAnlegen ernpReq = new PersonAnlegen(); + ernpReq.setBegruendung(PROCESS_ADD_IDENITY); + + // inject person data + Personendaten person = new Personendaten(); + person.setFamilienname(eidData.getFamilyName()); + person.setVorname(eidData.getGivenName()); + person.setGeburtsdatum(buildErnpBirthday(eidData.getDateOfBirth())); + ernpReq.setPersonendaten(person); - // request ZMR - log.trace("Requesting ERnP for '{}' operation", PROCESS_KITT_IDENITIES_UPDATE); - AendernResponse ernpResp = ernpClient.aendern(generic.getClientBehkz(), generic.clientName, + buildNewEidasDocumens(ernpReq, eidData); + + // request ERnP + log.trace("Requesting ERnP for '{}' operation", PROCESS_ADD_IDENITY); + AnlegenResponse ernpResp = ernpClient.anlegen(generic.getClientBehkz(), generic.clientName, generic.getClientRequestTime(), generic.getClientRequestId(), ernpReq); - log.trace("Receive response from ERnP for '{}' operation", PROCESS_KITT_IDENITIES_UPDATE); + log.trace("Receive response from ERnP for '{}' operation", PROCESS_ADD_IDENITY); return new ErnpRegisterResult(Arrays.asList( - mapErnpResponseToRegisterResult(ernpResp.getPerson(), citizenCountryCode))); - - } - - private Collection selectEidasDocumentsToAdd( - Person ernpPersonToKitt, SimpleEidasData eidData) { - - //TODO: maybe we should re-factor SimpleEidasData to a generic data-model to facilitate arbitrary eIDAS attributes - Set result = new HashSet<>(); - addEidasDocumentIfNotAvailable(result, ernpPersonToKitt, eidData.getCitizenCountryCode(), - Constants.eIDAS_ATTRURN_PERSONALIDENTIFIER, eidData.getPseudonym(), true); - addEidasDocumentIfNotAvailable(result, ernpPersonToKitt, eidData.getCitizenCountryCode(), - Constants.eIDAS_ATTRURN_PLACEOFBIRTH, eidData.getPlaceOfBirth(), false); - addEidasDocumentIfNotAvailable(result, ernpPersonToKitt, eidData.getCitizenCountryCode(), - Constants.eIDAS_ATTRURN_BIRTHNAME, eidData.getBirthName(), false); - - return result; + mapErnpResponseToRegisterResult(ernpResp.getPerson(), eidData.getCitizenCountryCode()))); } - private void addEidasDocumentIfNotAvailable(Set result, - Person ernpPersonToKitt, String citizenCountryCode, - String attrName, String attrValue, boolean allowMoreThanOneEntry) { - - if (StringUtils.isEmpty(attrValue)) { - log.trace("No eIDAS document: {}. Nothing todo for KITT process ... ", attrName); - return; - - } - - // check if eIDAS attribute is already includes an eIDAS-Document - boolean alreadyExist = ernpPersonToKitt.getEidas().stream() - .filter(el -> el.getWert().equals(attrValue) - && el.getArt().equals(attrName) - && el.getStaatscode2().equals(citizenCountryCode)) - .findAny() - .isPresent(); - - if (!alreadyExist) { - // check eIDAS documents already contains a document with this pair of country-code and attribute-name - Optional oneDocWithNameExists = ernpPersonToKitt.getEidas().stream() - .filter(el -> el.getStaatscode2().equals(citizenCountryCode) - && el.getArt().equals(attrName)) - .findAny(); - - if (!allowMoreThanOneEntry && oneDocWithNameExists.isPresent() - && !oneDocWithNameExists.get().getWert().equals(attrValue)) { - log.warn("eIDAS document: {} already exists for country: {} but attribute-value does not match. " - + "Skip update process because no multi-value allowed for this ... ", - attrName, citizenCountryCode); - - } else { - - Eidas eidasDocToAdd = new Eidas(); - eidasDocToAdd.setStaatscode2(citizenCountryCode); - eidasDocToAdd.setArt(attrName); - eidasDocToAdd.setWert(attrValue); - log.info("Add eIDAS document: {} for country: {} to ERnP person", attrName, citizenCountryCode); - result.add(eidasDocToAdd); - - } - - } else { - log.debug("eIDAS document: {} already exists for country: {}. Skip update process for this ... ", - attrName, citizenCountryCode); - - } - } - - private Person searchPersonForUpdate(RegisterResult registerResult) throws WorkflowException { - // build generic request metadata - final GenericRequestParams generic = buildGenericRequestParameters("stepKittSearch"); - - // build search request - final Suchdaten searchInfos = new Suchdaten(); - searchInfos.setFamilienname(registerResult.getFamilyName()); - searchInfos.setVorname(registerResult.getGivenName()); - searchInfos.setGeburtsdatum(buildErnpBirthday(registerResult.getDateOfBirth())); - - final PersonSuchen personSuchen = new PersonSuchen(); - personSuchen.setSuchoptionen(generateSearchParameters()); - personSuchen.setBegruendung(PROCESS_KITT_IDENITIES_GET); - personSuchen.setSuchdaten(searchInfos); - - // request ERnP - log.trace("Requesting ERnP for '{}' operation", PROCESS_KITT_IDENITIES_GET); - final SuchenResponse resp = ernpClient.suchen(generic.getClientBehkz(), generic.clientName, - generic.getClientRequestTime(), generic.getClientRequestId(), personSuchen); - - // perform shot validation of ERnP response - if (resp.getPerson() == null || resp.getPerson().size() != 1) { - log.error("ERnP result contains NO 'Person' or 'Person' is empty"); - throw new WorkflowException(PROCESS_KITT_IDENITIES_GET, - "Find NO data-set with already matchted eID during ERnP KITT process"); - - } else { - log.debug("Find person for '{}' operation", PROCESS_KITT_IDENITIES_GET); - return resp.getPerson().get(0); - - } - } - @Override public ErnpRegisterResult searchWithResidenceData(String givenName, String familyName, String dateOfBirth, String zipcode, String city, String street) { return new ErnpRegisterResult(Collections.emptyList()); } - + @PostConstruct private void initialize() throws EaafException { // validate additional Ernp communication parameters @@ -545,6 +449,161 @@ public class ErnpRestClient implements IErnpClient { } + private ErnpRegisterResult updatePersonInErnp(Person ernpPersonToKitt, + Collection eidasDocumentToAdd, String citizenCountryCode) + throws ServiceFault { + // build generic request metadata + final GenericRequestParams generic = buildGenericRequestParameters("stepKittUpdate"); + + // build update request + PersonAendern ernpReq = new PersonAendern(); + ernpReq.setBegruendung(PROCESS_KITT_IDENITIES_UPDATE); + + // set reference elements for person update + ernpReq.setEntityId(ernpPersonToKitt.getEntityId()); + ernpReq.setVersion(ernpPersonToKitt.getVersion()); + + // add new eIDAS attributes + eidasDocumentToAdd.stream().forEach(el -> ernpReq.getAnlegen().addEidasItem(el)); + + // TODO: should we update MDS also in that step? + + + // request ERnP + log.trace("Requesting ERnP for '{}' operation", PROCESS_KITT_IDENITIES_UPDATE); + AendernResponse ernpResp = ernpClient.aendern(generic.getClientBehkz(), generic.clientName, + generic.getClientRequestTime(), generic.getClientRequestId(), ernpReq); + log.trace("Receive response from ERnP for '{}' operation", PROCESS_KITT_IDENITIES_UPDATE); + + return new ErnpRegisterResult(Arrays.asList( + mapErnpResponseToRegisterResult(ernpResp.getPerson(), citizenCountryCode))); + + } + + private Collection selectEidasDocumentsToAdd( + Person ernpPersonToKitt, SimpleEidasData eidData) { + + //TODO: maybe we should re-factor SimpleEidasData to a generic data-model to facilitate arbitrary eIDAS attributes + Set result = new HashSet<>(); + addEidasDocumentIfNotAvailable(result, ernpPersonToKitt, eidData.getCitizenCountryCode(), + Constants.eIDAS_ATTRURN_PERSONALIDENTIFIER, eidData.getPseudonym(), true); + addEidasDocumentIfNotAvailable(result, ernpPersonToKitt, eidData.getCitizenCountryCode(), + Constants.eIDAS_ATTRURN_PLACEOFBIRTH, eidData.getPlaceOfBirth(), false); + addEidasDocumentIfNotAvailable(result, ernpPersonToKitt, eidData.getCitizenCountryCode(), + Constants.eIDAS_ATTRURN_BIRTHNAME, eidData.getBirthName(), false); + + return result; + + } + + private void addEidasDocumentIfNotAvailable(Set result, + Person ernpPersonToKitt, String citizenCountryCode, + String attrName, String attrValue, boolean allowMoreThanOneEntry) { + + if (StringUtils.isEmpty(attrValue)) { + log.trace("No eIDAS document: {}. Nothing todo for KITT process ... ", attrName); + return; + + } + + // check if eIDAS attribute is already includes an eIDAS-Document + boolean alreadyExist = ernpPersonToKitt.getEidas().stream() + .filter(el -> el.getWert().equals(attrValue) + && el.getArt().equals(attrName) + && el.getStaatscode2().equals(citizenCountryCode)) + .findAny() + .isPresent(); + + if (!alreadyExist) { + // check eIDAS documents already contains a document with this pair of country-code and attribute-name + Optional oneDocWithNameExists = ernpPersonToKitt.getEidas().stream() + .filter(el -> el.getStaatscode2().equals(citizenCountryCode) + && el.getArt().equals(attrName)) + .findAny(); + + if (!allowMoreThanOneEntry && oneDocWithNameExists.isPresent() + && !oneDocWithNameExists.get().getWert().equals(attrValue)) { + log.warn("eIDAS document: {} already exists for country: {} but attribute-value does not match. " + + "Skip update process because no multi-value allowed for this ... ", + attrName, citizenCountryCode); + + } else { + + Eidas eidasDocToAdd = new Eidas(); + eidasDocToAdd.setStaatscode2(citizenCountryCode); + eidasDocToAdd.setArt(attrName); + eidasDocToAdd.setWert(attrValue); + log.info("Add eIDAS document: {} for country: {} to ERnP person", attrName, citizenCountryCode); + result.add(eidasDocToAdd); + + } + + } else { + log.debug("eIDAS document: {} already exists for country: {}. Skip update process for this ... ", + attrName, citizenCountryCode); + + } + } + + private Person searchPersonForUpdate(RegisterResult registerResult) throws WorkflowException { + // build generic request metadata + final GenericRequestParams generic = buildGenericRequestParameters("stepKittSearch"); + + // build search request + final Suchdaten searchInfos = new Suchdaten(); + searchInfos.setFamilienname(registerResult.getFamilyName()); + searchInfos.setVorname(registerResult.getGivenName()); + searchInfos.setGeburtsdatum(buildErnpBirthday(registerResult.getDateOfBirth())); + + final PersonSuchen personSuchen = new PersonSuchen(); + personSuchen.setSuchoptionen(generateSearchParameters()); + personSuchen.setBegruendung(PROCESS_KITT_IDENITIES_GET); + personSuchen.setSuchdaten(searchInfos); + + // request ERnP + log.trace("Requesting ERnP for '{}' operation", PROCESS_KITT_IDENITIES_GET); + final SuchenResponse resp = ernpClient.suchen(generic.getClientBehkz(), generic.clientName, + generic.getClientRequestTime(), generic.getClientRequestId(), personSuchen); + + // perform shot validation of ERnP response + if (resp.getPerson() == null || resp.getPerson().size() != 1) { + log.error("ERnP result contains NO 'Person' or 'Person' is empty"); + throw new WorkflowException(PROCESS_KITT_IDENITIES_GET, + "Find NO data-set with already matchted eID during ERnP KITT process"); + + } else { + log.debug("Find person for '{}' operation", PROCESS_KITT_IDENITIES_GET); + return resp.getPerson().get(0); + + } + } + + private void buildNewEidasDocumens(PersonAnlegen ernpReq, SimpleEidasData eidData) { + ernpReq.addEidasItem(buildNewEidasDocument(eidData.getCitizenCountryCode(), + Constants.eIDAS_ATTRURN_PERSONALIDENTIFIER, eidData.getPseudonym())); + + if (StringUtils.isNotEmpty(eidData.getPlaceOfBirth())) { + ernpReq.addEidasItem(buildNewEidasDocument(eidData.getCitizenCountryCode(), + Constants.eIDAS_ATTRURN_PLACEOFBIRTH, eidData.getPlaceOfBirth())); + + } + + if (StringUtils.isNotEmpty(eidData.getBirthName())) { + ernpReq.addEidasItem(buildNewEidasDocument(eidData.getCitizenCountryCode(), + Constants.eIDAS_ATTRURN_BIRTHNAME, eidData.getBirthName())); + + } + } + + private Eidas buildNewEidasDocument(String citizenCountryCode, String eidasAttrName, + String eidasAddrValue) { + Eidas el = new Eidas(); + el.setArt(eidasAttrName); + el.setWert(eidasAddrValue); + el.setStaatscode2(citizenCountryCode); + return el; + } + /** * Map an AT specific Date String 'yyyy-MM-dd' to ERnP birthday representation. * diff --git a/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/clients/ernp/IErnpClient.java b/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/clients/ernp/IErnpClient.java index 4c8bcd3e..7a957531 100644 --- a/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/clients/ernp/IErnpClient.java +++ b/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/clients/ernp/IErnpClient.java @@ -84,6 +84,18 @@ public interface IErnpClient { ErnpRegisterResult update(RegisterResult registerResult, SimpleEidasData eidData) throws EidasSAuthenticationException; + + /** + * Add new entry into ERnP by using identity from this eIDAS authentication. + * + * @param eidData eIDAS eID information from current authentication process + * @return Update result but never null + * @throws EidasSAuthenticationException In case of a communication error + */ + @Nonnull + ErnpRegisterResult add(SimpleEidasData eidData) throws EidasSAuthenticationException; + + /** * Search person based on address information. * diff --git a/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/ernp/DummyErnpClient.java b/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/ernp/DummyErnpClient.java index 52703232..dabb73dc 100644 --- a/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/ernp/DummyErnpClient.java +++ b/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/ernp/DummyErnpClient.java @@ -73,4 +73,9 @@ public class DummyErnpClient implements IErnpClient { } + @Override + public ErnpRegisterResult add(SimpleEidasData eidData) throws EidasSAuthenticationException { + return buildEmptyResult(); + } + } diff --git a/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/tasks/CreateNewErnpEntryTask.java b/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/tasks/CreateNewErnpEntryTask.java index 6fc6d499..b63b0ca0 100644 --- a/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/tasks/CreateNewErnpEntryTask.java +++ b/eidas_modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/tasks/CreateNewErnpEntryTask.java @@ -26,9 +26,16 @@ package at.asitplus.eidas.specific.modules.auth.eidas.v2.tasks; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import at.asitplus.eidas.specific.modules.auth.eidas.v2.Constants; +import at.asitplus.eidas.specific.modules.auth.eidas.v2.clients.ernp.ErnpRestClient; +import at.asitplus.eidas.specific.modules.auth.eidas.v2.clients.ernp.ErnpRestClient.ErnpRegisterResult; +import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.MatchedPersonResult; +import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.SimpleEidasData; +import at.asitplus.eidas.specific.modules.auth.eidas.v2.exception.WorkflowException; +import at.asitplus.eidas.specific.modules.auth.eidas.v2.utils.MatchingTaskUtils; import at.gv.egiz.eaaf.core.api.idp.process.ExecutionContext; import at.gv.egiz.eaaf.core.exceptions.TaskExecutionException; import at.gv.egiz.eaaf.core.impl.idp.auth.modules.AbstractAuthServletTask; @@ -46,6 +53,7 @@ import lombok.extern.slf4j.Slf4j; *
  • TODO MDS, BPK of new entry
  • * * + * @author tlenz * @author amarsalek * @author ckollmann */ @@ -53,36 +61,40 @@ import lombok.extern.slf4j.Slf4j; @Component("CreateNewErnbEntryTask") public class CreateNewErnpEntryTask extends AbstractAuthServletTask { - //private final SzrClient szrClient; + private final ErnpRestClient ernpClient; - ///** - // * Constructor. - // * @param szrClient SZR client for creating a new ERnP entry - // */ - //public CreateNewErnpEntryTask(SzrClient szrClient) { - // this.szrClient = szrClient; - //} + /** + * Constructor. + * @param szrClient SZR client for creating a new ERnP entry + */ + public CreateNewErnpEntryTask(@Autowired ErnpRestClient client) { + this.ernpClient = client; + } @Override public void execute(ExecutionContext executionContext, HttpServletRequest request, HttpServletResponse response) throws TaskExecutionException { try { - //SimpleEidasData simpleEidasData = MatchingTaskUtils.getInitialEidasData(pendingReq); - - // insert person into ERnP - //TODO: should we insert it directly into ERnP? - //TODO: has to updated to new eIDAS document model in ERnP - //String vsz = szrClient.createNewErnpEntry(simpleEidasData); - - // finish matching process, because new user-entry uniquly matches - //log.info("User successfully registerred into ERnP and matching tasks are finished "); - //MatchingTaskUtils.storeFinalMatchingResult(pendingReq, - // MatchedPersonResult.builder() - // .vsz(vsz) - // .build()); - - log.warn("Skipping new insert ERnP task, because it's currently unknown who we should it"); + SimpleEidasData simpleEidasData = MatchingTaskUtils.getInitialEidasData(pendingReq); + if (simpleEidasData == null) { + throw new WorkflowException("step09", "No initial eIDAS authn data", true); + + } + //add person into ERnP + ErnpRegisterResult resp = ernpClient.add(simpleEidasData); + if (resp.getPersonResult().size() != 1) { + log.error("Receive {} from ERnP during 'add person' step", + resp.getPersonResult().isEmpty() ? "no result" : "more-than-one result"); + throw new WorkflowException("step09", "Add person into ERnP failed", true); + + } + + // finish matching process, because new user-entry uniquly matches + log.info("User successfully registerred into ERnP and matching tasks are finished "); + MatchingTaskUtils.storeFinalMatchingResult(pendingReq, + MatchedPersonResult.generateFormMatchingResult( + resp.getPersonResult().get(0), simpleEidasData.getCitizenCountryCode())); } catch (final Exception e) { log.error("Initial search FAILED.", e); -- cgit v1.2.3