diff --git a/README.md b/README.md index 570244e11..95a6ef873 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - 🖥️ Modern looking desktop user interface with several themes including dark mode - 📶 Remote access - 📖 Free software ([GPL](https://www.gnu.org/licenses/quick-guide-gplv3.html)) -- 😃 Available for Windows, Linux and MacOS +- 😃 Available for Windows and Linux ## Releases diff --git a/app/src/main/java/io/xeres/app/api/DefaultHandler.java b/app/src/main/java/io/xeres/app/api/DefaultHandler.java index 7929160c5..47bc9f6b3 100644 --- a/app/src/main/java/io/xeres/app/api/DefaultHandler.java +++ b/app/src/main/java/io/xeres/app/api/DefaultHandler.java @@ -35,6 +35,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.async.AsyncRequestNotUsableException; +import org.springframework.web.server.ResponseStatusException; import java.net.UnknownHostException; import java.util.NoSuchElementException; @@ -93,6 +94,19 @@ public ResponseEntity handleRuntimeException(RuntimeException e) .build(); } + /** + * Generates a ResponseStatusException. Those are typically done from media endpoints + * and there's no way to put JSON error messages in there, so just ignore them. + * + * @param e the exception + * @return a ResponseEntity with just the status code and no message + */ + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity handleResponseStatusException(ResponseStatusException e) + { + return new ResponseEntity<>(e.getStatusCode()); + } + @ExceptionHandler(AsyncRequestNotUsableException.class) public void handleAsyncRequestNotUsableException(AsyncRequestNotUsableException ignored) { diff --git a/app/src/main/java/io/xeres/app/api/controller/identity/IdentityController.java b/app/src/main/java/io/xeres/app/api/controller/identity/IdentityController.java index 5c4d4805a..244566345 100644 --- a/app/src/main/java/io/xeres/app/api/controller/identity/IdentityController.java +++ b/app/src/main/java/io/xeres/app/api/controller/identity/IdentityController.java @@ -37,6 +37,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.ByteArrayInputStream; @@ -77,7 +78,7 @@ public IdentityDTO findIdentityById(@PathVariable long id) @ApiResponse(responseCode = "404", description = "Identity not found", content = @Content(schema = @Schema(implementation = Error.class))) public ResponseEntity downloadIdentityImage(@PathVariable long id) { - var identity = identityRsService.findById(id).orElseThrow(); + var identity = identityRsService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype var imageType = ImageDetectionUtils.getImageMimeType(identity.getImage()); if (imageType == null) { diff --git a/app/src/main/java/io/xeres/app/api/controller/location/LocationController.java b/app/src/main/java/io/xeres/app/api/controller/location/LocationController.java index 31ac486c2..58789a7df 100644 --- a/app/src/main/java/io/xeres/app/api/controller/location/LocationController.java +++ b/app/src/main/java/io/xeres/app/api/controller/location/LocationController.java @@ -31,9 +31,11 @@ import io.xeres.common.rest.Error; import io.xeres.common.rest.location.RSIdResponse; import io.xeres.common.rsid.Type; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; import java.awt.image.BufferedImage; @@ -81,7 +83,7 @@ public RSIdResponse getRSIdOfLocation(@PathVariable long id, @RequestParam(value @ApiResponse(responseCode = "404", description = "Profile not found", content = @Content(schema = @Schema(implementation = Error.class))) public ResponseEntity getRSIdOfLocationAsQrCode(@PathVariable long id) { - var location = locationService.findLocationById(id).orElseThrow(); + var location = locationService.findLocationById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype return ResponseEntity.ok(qrCodeService.generateQrCode(location.getRsId(Type.SHORT_INVITE).getArmored())); } diff --git a/app/src/main/java/io/xeres/app/api/controller/notification/NotificationController.java b/app/src/main/java/io/xeres/app/api/controller/notification/NotificationController.java index 678f14e62..55bf21d74 100644 --- a/app/src/main/java/io/xeres/app/api/controller/notification/NotificationController.java +++ b/app/src/main/java/io/xeres/app/api/controller/notification/NotificationController.java @@ -23,6 +23,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import io.xeres.app.service.notification.contact.ContactNotificationService; import io.xeres.app.service.notification.file.FileNotificationService; import io.xeres.app.service.notification.file.FileSearchNotificationService; import io.xeres.app.service.notification.forum.ForumNotificationService; @@ -44,13 +45,15 @@ public class NotificationController private final ForumNotificationService forumNotificationService; private final FileNotificationService fileNotificationService; private final FileSearchNotificationService fileSearchNotificationService; + private final ContactNotificationService contactNotificationService; - public NotificationController(StatusNotificationService statusNotificationService, ForumNotificationService forumNotificationService, FileNotificationService fileNotificationService, FileSearchNotificationService fileSearchNotificationService) + public NotificationController(StatusNotificationService statusNotificationService, ForumNotificationService forumNotificationService, FileNotificationService fileNotificationService, FileSearchNotificationService fileSearchNotificationService, ContactNotificationService contactNotificationService) { this.statusNotificationService = statusNotificationService; this.forumNotificationService = forumNotificationService; this.fileNotificationService = fileNotificationService; this.fileSearchNotificationService = fileSearchNotificationService; + this.contactNotificationService = contactNotificationService; } @GetMapping("/status") @@ -84,4 +87,12 @@ public SseEmitter setupFileSearchNotification() { return fileSearchNotificationService.addClient(); } + + @GetMapping("/contact") + @Operation(summary = "Subscribe to contact notifications") + @ApiResponse(responseCode = "200", description = "Request completed successfully") + public SseEmitter setupContactNotification() + { + return contactNotificationService.addClient(); + } } diff --git a/app/src/main/java/io/xeres/app/api/controller/profile/ProfileController.java b/app/src/main/java/io/xeres/app/api/controller/profile/ProfileController.java index 35e57008a..a714997d2 100644 --- a/app/src/main/java/io/xeres/app/api/controller/profile/ProfileController.java +++ b/app/src/main/java/io/xeres/app/api/controller/profile/ProfileController.java @@ -119,7 +119,7 @@ public ResponseEntity createProfileFromRsId(@Valid @RequestBody RsIdReques profile.setTrust(trust); } - var savedProfile = profileService.createOrUpdateProfile(profile).orElseThrow(() -> new UnprocessableEntityException("Failed to save profile")); + var savedProfile = profileService.createOrUpdateProfile(profile); statusNotificationService.incrementTotalUsers(); // not correct if a certificate is used to update an existing profile (XXX: put a created date? or age? that would detect it) diff --git a/app/src/main/java/io/xeres/app/application/Startup.java b/app/src/main/java/io/xeres/app/application/Startup.java index b9de2da2b..b5ee1ef00 100644 --- a/app/src/main/java/io/xeres/app/application/Startup.java +++ b/app/src/main/java/io/xeres/app/application/Startup.java @@ -27,20 +27,15 @@ import io.xeres.app.configuration.DataDirConfiguration; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; -import io.xeres.app.database.model.file.File; import io.xeres.app.database.model.settings.Settings; -import io.xeres.app.database.model.share.Share; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.*; -import io.xeres.app.service.file.FileService; import io.xeres.app.service.notification.file.FileNotificationService; import io.xeres.app.service.notification.status.StatusNotificationService; import io.xeres.app.xrs.service.identity.IdentityManager; import io.xeres.common.AppName; import io.xeres.common.events.StartupEvent; import io.xeres.common.mui.MinimalUserInterface; -import io.xeres.common.pgp.Trust; -import io.xeres.common.util.SecureRandomUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.ApplicationArguments; @@ -50,12 +45,8 @@ import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; -import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; @Component public class Startup implements ApplicationRunner @@ -72,13 +63,13 @@ public class Startup implements ApplicationRunner private final IdentityManager identityManager; private final StatusNotificationService statusNotificationService; private final AutoStart autoStart; - private final FileService fileService; private final ShellService shellService; private final FileNotificationService fileNotificationService; private final InfoService infoService; + private final UpgradeService upgradeService; private final ApplicationEventPublisher publisher; - public Startup(LocationService locationService, SettingsService settingsService, DatabaseSessionManager databaseSessionManager, DataDirConfiguration dataDirConfiguration, NetworkService networkService, PeerConnectionManager peerConnectionManager, UiBridgeService uiBridgeService, IdentityManager identityManager, StatusNotificationService statusNotificationService, AutoStart autoStart, FileService fileService, ShellService shellService, FileNotificationService fileNotificationService, InfoService infoService, ApplicationEventPublisher publisher) + public Startup(LocationService locationService, SettingsService settingsService, DatabaseSessionManager databaseSessionManager, DataDirConfiguration dataDirConfiguration, NetworkService networkService, PeerConnectionManager peerConnectionManager, UiBridgeService uiBridgeService, IdentityManager identityManager, StatusNotificationService statusNotificationService, AutoStart autoStart, ShellService shellService, FileNotificationService fileNotificationService, InfoService infoService, UpgradeService upgradeService, ApplicationEventPublisher publisher) { this.locationService = locationService; this.settingsService = settingsService; @@ -90,10 +81,10 @@ public Startup(LocationService locationService, SettingsService settingsService, this.identityManager = identityManager; this.statusNotificationService = statusNotificationService; this.autoStart = autoStart; - this.fileService = fileService; this.shellService = shellService; this.fileNotificationService = fileNotificationService; this.infoService = infoService; + this.upgradeService = upgradeService; this.publisher = publisher; } @@ -117,7 +108,7 @@ public void run(ApplicationArguments args) return; } - configureDefaults(); + upgradeService.upgrade(); if (networkService.checkReadiness()) { @@ -229,55 +220,4 @@ private void syncAutoStart() } } } - - /** - * Configures defaults that cannot be done on the database definition because - * they depend on some runtime parameters. This is not called in UI client - * only mode. - */ - private void configureDefaults() - { - var version = 2; // Increment this number when needing to add new defaults - - // Don't do this stuff when running tests - if (dataDirConfiguration.getDataDir() == null) - { - return; - } - - if (!settingsService.hasIncomingDirectory()) - { - var incomingDirectory = Path.of(dataDirConfiguration.getDataDir(), "Incoming"); - if (Files.notExists(incomingDirectory)) - { - try - { - Files.createDirectory(incomingDirectory); - } - catch (IOException e) - { - throw new IllegalStateException("Couldn't create incoming directory: " + incomingDirectory + ", :" + e.getMessage()); - } - } - settingsService.setIncomingDirectory(incomingDirectory.toString()); - fileService.addShare(Share.createShare("Incoming", File.createFile(incomingDirectory), false, Trust.UNKNOWN)); - } - - if (settingsService.getVersion() < 1) - { - var password = new char[20]; - SecureRandomUtils.nextPassword(password); - settingsService.setRemotePassword(String.valueOf(password)); - Arrays.fill(password, (char) 0); - } - - if (settingsService.getVersion() < 2) - { - fileService.encryptAllHashes(); - } - - // [Add new defaults here] - - settingsService.setVersion(version); - } } diff --git a/app/src/main/java/io/xeres/app/crypto/pgp/PGP.java b/app/src/main/java/io/xeres/app/crypto/pgp/PGP.java index 0689caa5d..6f0be88ef 100644 --- a/app/src/main/java/io/xeres/app/crypto/pgp/PGP.java +++ b/app/src/main/java/io/xeres/app/crypto/pgp/PGP.java @@ -250,6 +250,32 @@ public static void sign(PGPSecretKey pgpSecretKey, InputStream in, OutputStream * @throws PGPException if there's a PGP error */ public static void verify(PGPPublicKey pgpPublicKey, byte[] signature, InputStream in) throws IOException, SignatureException, PGPException + { + var pgpSignature = getSignature(signature); + + pgpSignature.init(new BcPGPContentVerifierBuilderProvider(), pgpPublicKey); + pgpSignature.update(in.readAllBytes()); + in.close(); + if (!pgpSignature.verify()) + { + throw new SignatureException("Wrong signature"); + } + } + + public static long getIssuer(byte[] signature) + { + try + { + var pgpSignature = getSignature(signature); + return pgpSignature.getKeyID(); + } + catch (SignatureException | IOException e) + { + return 0L; + } + } + + private static PGPSignature getSignature(byte[] signature) throws SignatureException, IOException { var pgpObjectFactory = new PGPObjectFactory(signature, new BcKeyFingerprintCalculator()); @@ -284,14 +310,7 @@ public static void verify(PGPPublicKey pgpPublicKey, byte[] signature, InputStre { throw new SignatureException("Signature hash algorithm is not of SHA family (" + pgpSignature.getHashAlgorithm() + ")"); } - - pgpSignature.init(new BcPGPContentVerifierBuilderProvider(), pgpPublicKey); - pgpSignature.update(in.readAllBytes()); - in.close(); - if (!pgpSignature.verify()) - { - throw new SignatureException("Wrong signature"); - } + return pgpSignature; } /** diff --git a/app/src/main/java/io/xeres/app/database/model/chat/ChatRoomBacklog.java b/app/src/main/java/io/xeres/app/database/model/chat/ChatRoomBacklog.java index c48355a58..efab2f6c0 100644 --- a/app/src/main/java/io/xeres/app/database/model/chat/ChatRoomBacklog.java +++ b/app/src/main/java/io/xeres/app/database/model/chat/ChatRoomBacklog.java @@ -60,9 +60,10 @@ public ChatRoomBacklog(ChatRoom room, GxsId gxsId, String nickname, String messa this.message = message; } - public ChatRoomBacklog(ChatRoom room, String message) + public ChatRoomBacklog(ChatRoom room, String nickname, String message) { this.room = room; + this.nickname = nickname; this.message = message; } diff --git a/app/src/main/java/io/xeres/app/database/model/profile/Profile.java b/app/src/main/java/io/xeres/app/database/model/profile/Profile.java index 6f18d556c..05bdc75a8 100644 --- a/app/src/main/java/io/xeres/app/database/model/profile/Profile.java +++ b/app/src/main/java/io/xeres/app/database/model/profile/Profile.java @@ -34,6 +34,7 @@ import org.bouncycastle.util.encoders.Hex; import java.io.IOException; +import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -54,6 +55,8 @@ public class Profile @NotNull private long pgpIdentifier; + private Instant created; + @Embedded @NotNull @AttributeOverride(name = "identifier", column = @Column(name = "pgp_fingerprint")) @@ -74,25 +77,25 @@ protected Profile() } // This is only used for unit tests - protected Profile(long id, String name, long pgpIdentifier, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData) + protected Profile(long id, String name, long pgpIdentifier, Instant created, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData) { - this(name, pgpIdentifier, profileFingerprint, pgpPublicKeyData); + this(name, pgpIdentifier, created, profileFingerprint, pgpPublicKeyData); this.id = id; } - public static Profile createOwnProfile(String name, long pgpIdentifier, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData) + public static Profile createOwnProfile(String name, long pgpIdentifier, Instant created, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData) { - var profile = new Profile(name, pgpIdentifier, profileFingerprint, pgpPublicKeyData); + var profile = new Profile(name, pgpIdentifier, created, profileFingerprint, pgpPublicKeyData); profile.setTrust(Trust.ULTIMATE); profile.setAccepted(true); return profile; } - public static Profile createProfile(String name, long pgpIdentifier, ProfileFingerprint pgpFingerprint, PGPPublicKey pgpPublicKey) + public static Profile createProfile(String name, long pgpIdentifier, Instant created, ProfileFingerprint pgpFingerprint, PGPPublicKey pgpPublicKey) { try { - return createProfile(name, pgpIdentifier, pgpFingerprint, pgpPublicKey.getEncoded()); + return createProfile(name, pgpIdentifier, created, pgpFingerprint, pgpPublicKey.getEncoded()); } catch (IOException e) { @@ -100,25 +103,21 @@ public static Profile createProfile(String name, long pgpIdentifier, ProfileFing } } - public static Profile createProfile(String name, long pgpIdentifier, byte[] pgpFingerprint, byte[] pgpPublicKeyData) + public static Profile createProfile(String name, long pgpIdentifier, Instant created, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData) { - return new Profile(name, pgpIdentifier, new ProfileFingerprint(pgpFingerprint), pgpPublicKeyData); - } - - public static Profile createProfile(String name, long pgpIdentifier, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData) - { - return new Profile(name, pgpIdentifier, profileFingerprint, pgpPublicKeyData); + return new Profile(name, pgpIdentifier, created, profileFingerprint, pgpPublicKeyData); } public static Profile createEmptyProfile(String name, long pgpIdentifier, ProfileFingerprint profileFingerprint) { - return new Profile(name, pgpIdentifier, profileFingerprint, null); + return new Profile(name, pgpIdentifier, null, profileFingerprint, null); } - private Profile(String name, long pgpIdentifier, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData) + private Profile(String name, long pgpIdentifier, Instant created, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData) { this.name = sanitizeProfileName(name); this.pgpIdentifier = pgpIdentifier; + this.created = created; this.profileFingerprint = profileFingerprint; this.pgpPublicKeyData = pgpPublicKeyData; } @@ -185,6 +184,16 @@ void setPgpIdentifier(long pgpIdentifier) this.pgpIdentifier = pgpIdentifier; } + public Instant getCreated() + { + return created; + } + + public void setCreated(Instant created) + { + this.created = created; + } + public ProfileFingerprint getProfileFingerprint() { return profileFingerprint; diff --git a/app/src/main/java/io/xeres/app/database/model/profile/ProfileMapper.java b/app/src/main/java/io/xeres/app/database/model/profile/ProfileMapper.java index d355ebc48..5d509f3e2 100644 --- a/app/src/main/java/io/xeres/app/database/model/profile/ProfileMapper.java +++ b/app/src/main/java/io/xeres/app/database/model/profile/ProfileMapper.java @@ -48,6 +48,7 @@ public static ProfileDTO toDTO(Profile profile) profile.getId(), profile.getName(), Long.toString(profile.getPgpIdentifier()), + profile.getCreated(), profile.getProfileFingerprint().getBytes(), profile.getPgpPublicKeyData(), profile.isAccepted(), @@ -115,6 +116,7 @@ public static Profile fromDTO(ProfileDTO dto) profile.setId(dto.id()); profile.setName(dto.name()); profile.setPgpIdentifier(Long.parseLong(dto.pgpIdentifier())); + profile.setCreated(dto.created()); profile.setProfileFingerprint(new ProfileFingerprint(dto.pgpFingerprint())); profile.setPgpPublicKeyData(dto.pgpPublicKeyData()); profile.setAccepted(dto.accepted()); diff --git a/app/src/main/java/io/xeres/app/database/repository/GxsIdentityRepository.java b/app/src/main/java/io/xeres/app/database/repository/GxsIdentityRepository.java index c52310d10..a1d24d7b3 100644 --- a/app/src/main/java/io/xeres/app/database/repository/GxsIdentityRepository.java +++ b/app/src/main/java/io/xeres/app/database/repository/GxsIdentityRepository.java @@ -22,6 +22,7 @@ import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.id.GxsId; import io.xeres.common.identity.Type; +import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; @@ -42,4 +43,6 @@ public interface GxsIdentityRepository extends JpaRepository findAllByType(Type type); List findAllBySubscribedIsTrueAndPublishedAfter(Instant since); + + List findAllByNextValidationNotNullAndNextValidationBeforeOrderByNextValidationDesc(Instant now, Limit limit); } diff --git a/app/src/main/java/io/xeres/app/service/ContactService.java b/app/src/main/java/io/xeres/app/service/ContactService.java index 98630eb49..fa440d9d5 100644 --- a/app/src/main/java/io/xeres/app/service/ContactService.java +++ b/app/src/main/java/io/xeres/app/service/ContactService.java @@ -19,38 +19,60 @@ package io.xeres.app.service; +import io.xeres.app.database.model.profile.Profile; import io.xeres.app.xrs.service.identity.IdentityRsService; +import io.xeres.app.xrs.service.identity.IdentityServiceStorage; +import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.rest.contact.Contact; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; @Service public class ContactService { private final ProfileService profileService; - private final LocationService locationService; private final IdentityRsService identityRsService; - public ContactService(ProfileService profileService, LocationService locationService, IdentityRsService identityRsService) + public ContactService(@Lazy ProfileService profileService, @Lazy IdentityRsService identityRsService) { this.profileService = profileService; - this.locationService = locationService; this.identityRsService = identityRsService; } @Transactional(readOnly = true) public List getContacts() { - var profiles = profileService.getAllProfiles(); + // XXX: this merges probably a bit too much (what if several identities point to the same profile? what if the identity name is different from the profile name?) + var profiles = profileService.getAllProfiles().stream() + .collect(Collectors.toMap(Profile::getId, profile -> profile)); var identities = identityRsService.getAll(); + var profilesIdsToRemove = identities.stream() + .filter(identity -> identity.getProfile() != null) + .map(identity -> identity.getProfile().getId()) + .collect(Collectors.toSet()); + + profiles.entrySet().removeIf(entry -> profilesIdsToRemove.contains(entry.getKey())); - // XXX: for now return all of them, in the future it would be possible to merge the identity to the profile (if the name is the same) List contacts = new ArrayList<>(profiles.size() + identities.size()); - profiles.forEach(profile -> contacts.add(new Contact(profile.getName(), profile.getId(), 0L))); - identities.forEach(identity -> contacts.add(new Contact(identity.getName(), 0L, identity.getId()))); // XXX: put the profile too + profiles.forEach((key, value) -> contacts.add(new Contact(value.getName(), key, 0L))); + identities.forEach(identity -> contacts.add(new Contact(identity.getName(), identity.getProfile() != null ? identity.getProfile().getId() : 0L, identity.getId()))); + return contacts; + } + + public List toContacts(List identities) + { + List contacts = new ArrayList<>(identities.size()); + identities.forEach(identity -> contacts.add(new Contact(identity.getName(), identity.getProfile() != null ? identity.getProfile().getId() : new IdentityServiceStorage(identity.getServiceString()).getPgpIdentifier(), identity.getId()))); return contacts; } + + public Contact toContact(Profile profile) + { + return new Contact(profile.getName(), profile.getId(), 0L); + } } diff --git a/app/src/main/java/io/xeres/app/service/ProfileService.java b/app/src/main/java/io/xeres/app/service/ProfileService.java index 4d8ebd35d..159d94f27 100644 --- a/app/src/main/java/io/xeres/app/service/ProfileService.java +++ b/app/src/main/java/io/xeres/app/service/ProfileService.java @@ -26,6 +26,7 @@ import io.xeres.app.database.model.profile.Profile; import io.xeres.app.database.repository.ProfileRepository; import io.xeres.app.net.peer.PeerConnectionManager; +import io.xeres.app.service.notification.contact.ContactNotificationService; import io.xeres.common.AppName; import io.xeres.common.dto.profile.ProfileConstants; import io.xeres.common.id.Id; @@ -42,6 +43,7 @@ import org.springframework.transaction.annotation.Transactional; import java.io.IOException; +import java.security.InvalidKeyException; import java.security.Security; import java.util.*; import java.util.stream.Collectors; @@ -63,14 +65,16 @@ public class ProfileService private final PeerConnectionManager peerConnectionManager; private final Map> profilesToDelete = HashMap.newHashMap(2); + private final ContactNotificationService contactNotificationService; - public ProfileService(ProfileRepository profileRepository, SettingsService settingsService, PeerConnectionManager peerConnectionManager) + public ProfileService(ProfileRepository profileRepository, SettingsService settingsService, PeerConnectionManager peerConnectionManager, ContactNotificationService contactNotificationService) { this.profileRepository = profileRepository; this.settingsService = settingsService; this.peerConnectionManager = peerConnectionManager; Security.addProvider(new BouncyCastleProvider()); + this.contactNotificationService = contactNotificationService; } @Transactional @@ -113,7 +117,7 @@ public ResourceCreationState generateProfileKeys(String name) @Transactional public void createOwnProfile(String name, PGPSecretKey pgpSecretKey, PGPPublicKey pgpPublicKey) throws IOException { - var ownProfile = Profile.createOwnProfile(name, pgpPublicKey.getKeyID(), new ProfileFingerprint(pgpPublicKey.getFingerprint()), pgpPublicKey.getEncoded()); + var ownProfile = Profile.createOwnProfile(name, pgpPublicKey.getKeyID(), pgpPublicKey.getCreationTime().toInstant(), new ProfileFingerprint(pgpPublicKey.getFingerprint()), pgpPublicKey.getEncoded()); profileRepository.save(ownProfile); settingsService.saveSecretProfileKey(pgpSecretKey.getEncoded()); } @@ -169,13 +173,19 @@ public Optional findProfileByLocationId(LocationId locationId) } @Transactional - public Optional createOrUpdateProfile(final Profile profile) + public Profile createOrUpdateProfile(final Profile profile) { Objects.requireNonNull(profile); - return Optional.of(profileRepository.save(findProfileByPgpFingerprint(profile.getProfileFingerprint()) + var savedProfile = profileRepository.save( + findProfileByPgpFingerprint(profile.getProfileFingerprint()) .map(foundProfile -> foundProfile.updateWith(profile)) - .orElse(profile))); + .orElse(profile) + ); + + contactNotificationService.addProfile(savedProfile); + + return savedProfile; } public Profile getProfileFromRSId(RSId rsId) @@ -191,7 +201,7 @@ private static Profile createNewProfile(RSId rsId) if (rsId.getPgpPublicKey().isPresent()) { var pgpPublicKey = rsId.getPgpPublicKey().get(); - return Profile.createProfile(pgpPublicKey.getUserIDs().next(), pgpPublicKey.getKeyID(), rsId.getPgpFingerprint(), pgpPublicKey); + return Profile.createProfile(pgpPublicKey.getUserIDs().next(), pgpPublicKey.getKeyID(), pgpPublicKey.getCreationTime().toInstant(), rsId.getPgpFingerprint(), pgpPublicKey); } return Profile.createEmptyProfile(rsId.getName(), rsId.getPgpIdentifier(), rsId.getPgpFingerprint()); } @@ -211,6 +221,7 @@ public void deleteProfile(long id) if (connectedLocations.isEmpty()) { profileRepository.delete(profile); + contactNotificationService.removeProfile(profile); } else { @@ -241,6 +252,7 @@ public void onPeerDisconnectedEvent(PeerDisconnectedEvent event) if (profileSetEntry.getValue().isEmpty()) { profileRepository.delete(profileSetEntry.getKey()); + contactNotificationService.removeProfile(profileSetEntry.getKey()); it.remove(); } } @@ -255,4 +267,29 @@ public List getAllDiscoverableProfiles() { return profileRepository.getAllDiscoverableProfiles(); } + + public List getAllProfilesIn(Set profileIds) + { + return profileRepository.findAllById(profileIds); + } + + @Transactional + public void fixAllProfiles() + { + profileRepository.findAll().forEach(profile -> { + var pgpPublicKeyData = profile.getPgpPublicKeyData(); + if (pgpPublicKeyData != null) + { + try + { + var pgpPublicKey = PGP.getPGPPublicKey(pgpPublicKeyData); + profile.setCreated(pgpPublicKey.getCreationTime().toInstant()); + } + catch (InvalidKeyException e) + { + // Skip + } + } + }); + } } diff --git a/app/src/main/java/io/xeres/app/service/UpgradeService.java b/app/src/main/java/io/xeres/app/service/UpgradeService.java new file mode 100644 index 000000000..e00078f3f --- /dev/null +++ b/app/src/main/java/io/xeres/app/service/UpgradeService.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2024 by David Gerber - https://zapek.com + * + * This file is part of Xeres. + * + * Xeres is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Xeres is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Xeres. If not, see . + */ + +package io.xeres.app.service; + +import io.xeres.app.configuration.DataDirConfiguration; +import io.xeres.app.database.model.file.File; +import io.xeres.app.database.model.share.Share; +import io.xeres.app.service.file.FileService; +import io.xeres.app.xrs.service.identity.IdentityRsService; +import io.xeres.common.pgp.Trust; +import io.xeres.common.util.SecureRandomUtils; +import org.bouncycastle.openpgp.PGPException; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +@Service +public class UpgradeService +{ + private final DataDirConfiguration dataDirConfiguration; + private final SettingsService settingsService; + private final FileService fileService; + private final IdentityRsService identityRsService; + private final ProfileService profileService; + + public UpgradeService(DataDirConfiguration dataDirConfiguration, SettingsService settingsService, FileService fileService, IdentityRsService identityRsService, ProfileService profileService) + { + this.dataDirConfiguration = dataDirConfiguration; + this.settingsService = settingsService; + this.fileService = fileService; + this.identityRsService = identityRsService; + this.profileService = profileService; + } + + /** + * Configures defaults and upgrades that cannot be done on the database definition alone because + * they depend on some runtime parameters. This is not called in UI client only mode. + */ + public void upgrade() + { + var version = 3; // Increment this number when needing to add new defaults + + // Don't do this stuff when running tests + if (dataDirConfiguration.getDataDir() == null) + { + return; + } + + if (!settingsService.hasIncomingDirectory()) + { + var incomingDirectory = Path.of(dataDirConfiguration.getDataDir(), "Incoming"); + if (Files.notExists(incomingDirectory)) + { + try + { + Files.createDirectory(incomingDirectory); + } + catch (IOException e) + { + throw new IllegalStateException("Couldn't create incoming directory: " + incomingDirectory + ", :" + e.getMessage()); + } + } + settingsService.setIncomingDirectory(incomingDirectory.toString()); + fileService.addShare(Share.createShare("Incoming", File.createFile(incomingDirectory), false, Trust.UNKNOWN)); + } + + if (settingsService.getVersion() < 1) + { + var password = new char[20]; + SecureRandomUtils.nextPassword(password); + settingsService.setRemotePassword(String.valueOf(password)); + Arrays.fill(password, (char) 0); + } + + if (settingsService.getVersion() < 2) + { + fileService.encryptAllHashes(); + } + + if (settingsService.getVersion() < 3) + { + try + { + identityRsService.fixOwnProfile(); + } + catch (PGPException | IOException e) + { + throw new IllegalStateException("Couldn't fix own profile hash + signature: " + e.getMessage()); + } + profileService.fixAllProfiles(); + } + + // [Add new defaults here] + + settingsService.setVersion(version); + } +} diff --git a/app/src/main/java/io/xeres/app/service/backup/BackupService.java b/app/src/main/java/io/xeres/app/service/backup/BackupService.java index feaab0e5d..48e368743 100644 --- a/app/src/main/java/io/xeres/app/service/backup/BackupService.java +++ b/app/src/main/java/io/xeres/app/service/backup/BackupService.java @@ -160,7 +160,7 @@ private void createProfiles(List pr { var pgpPublicKey = PGP.getPGPPublicKey(profile.getPgpPublicKeyData()); var createdProfile = io.xeres.app.database.model.profile.Profile.createProfile( - profile.getName(), profile.getPgpIdentifier(), new ProfileFingerprint(pgpPublicKey.getFingerprint()), pgpPublicKey); + profile.getName(), profile.getPgpIdentifier(), pgpPublicKey.getCreationTime().toInstant(), new ProfileFingerprint(pgpPublicKey.getFingerprint()), pgpPublicKey); profile.getLocations().forEach(createdProfile::addLocation); createdProfile.setAccepted(true); profileService.createOrUpdateProfile(createdProfile); diff --git a/app/src/main/java/io/xeres/app/service/notification/contact/ContactNotificationService.java b/app/src/main/java/io/xeres/app/service/notification/contact/ContactNotificationService.java new file mode 100644 index 000000000..10928c17d --- /dev/null +++ b/app/src/main/java/io/xeres/app/service/notification/contact/ContactNotificationService.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2024 by David Gerber - https://zapek.com + * + * This file is part of Xeres. + * + * Xeres is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Xeres is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Xeres. If not, see . + */ + +package io.xeres.app.service.notification.contact; + +import io.xeres.app.database.model.profile.Profile; +import io.xeres.app.service.ContactService; +import io.xeres.app.service.notification.NotificationService; +import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; +import io.xeres.common.rest.contact.Contact; +import io.xeres.common.rest.notification.Notification; +import io.xeres.common.rest.notification.contact.AddContacts; +import io.xeres.common.rest.notification.contact.ContactNotification; +import io.xeres.common.rest.notification.contact.RemoveContacts; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class ContactNotificationService extends NotificationService +{ + private final ContactService contactService; + + public ContactNotificationService(ContactService contactService) + { + this.contactService = contactService; + } + + public void addIdentities(List identities) + { + addContacts(contactService.toContacts(identities)); + } + + public void removeIdentities(List identities) + { + removeContacts(contactService.toContacts(identities)); + } + + public void addProfile(Profile profile) + { + addContacts(List.of(contactService.toContact(profile))); + } + + public void removeProfile(Profile profile) + { + removeContacts(List.of(contactService.toContact(profile))); + } + + private void addContacts(List contacts) + { + var action = new AddContacts(contacts); + sendNotification(new ContactNotification(action.getClass().getSimpleName(), action)); + } + + private void removeContacts(List contacts) + { + var action = new RemoveContacts(contacts); + sendNotification(new ContactNotification(action.getClass().getSimpleName(), action)); + } + + @Override + protected Notification createNotification() + { + return null; + } +} diff --git a/app/src/main/java/io/xeres/app/xrs/service/chat/ChatBacklogService.java b/app/src/main/java/io/xeres/app/xrs/service/chat/ChatBacklogService.java index d986f0079..f84fee112 100644 --- a/app/src/main/java/io/xeres/app/xrs/service/chat/ChatBacklogService.java +++ b/app/src/main/java/io/xeres/app/xrs/service/chat/ChatBacklogService.java @@ -62,10 +62,10 @@ public void storeIncomingChatRoomMessage(long chatRoomId, GxsId from, String nic } @Transactional - public void storeOutgoingChatRoomMessage(long chatRoomId, String message) + public void storeOutgoingChatRoomMessage(long chatRoomId, String nickname, String message) { var chatRoom = chatRoomRepository.findByRoomId(chatRoomId).orElseThrow(); - chatRoomBacklogRepository.save(new ChatRoomBacklog(chatRoom, message)); + chatRoomBacklogRepository.save(new ChatRoomBacklog(chatRoom, nickname, message)); } @Transactional(readOnly = true) diff --git a/app/src/main/java/io/xeres/app/xrs/service/chat/ChatRsService.java b/app/src/main/java/io/xeres/app/xrs/service/chat/ChatRsService.java index 937671c4e..db263ce9d 100644 --- a/app/src/main/java/io/xeres/app/xrs/service/chat/ChatRsService.java +++ b/app/src/main/java/io/xeres/app/xrs/service/chat/ChatRsService.java @@ -1015,7 +1015,7 @@ public void sendChatRoomMessage(long chatRoomId, String message) } initializeBounce(chatRoom, chatRoomMessageItem); - chatBacklogService.storeOutgoingChatRoomMessage(chatRoomId, message); + chatBacklogService.storeOutgoingChatRoomMessage(chatRoomId, chatRoomMessageItem.getSenderNickname(), message); bounce(chatRoomMessageItem); } diff --git a/app/src/main/java/io/xeres/app/xrs/service/discovery/DiscoveryRsService.java b/app/src/main/java/io/xeres/app/xrs/service/discovery/DiscoveryRsService.java index b09d3207f..733467280 100644 --- a/app/src/main/java/io/xeres/app/xrs/service/discovery/DiscoveryRsService.java +++ b/app/src/main/java/io/xeres/app/xrs/service/discovery/DiscoveryRsService.java @@ -428,6 +428,7 @@ private void handlePgpKey(PeerConnection peerConnection, DiscoveryPgpKeyItem dis { // We can save its PGP key and promote it to full profile. profile.setPgpPublicKeyData(discoveryPgpKeyItem.getKeyData()); + profile.setCreated(pgpPublicKey.getCreationTime().toInstant()); profileService.createOrUpdateProfile(profile); sendOwnContacts(peerConnection); @@ -454,7 +455,7 @@ private static Profile createNewProfile(PGPPublicKey pgpPublicKey) { try { - return Profile.createProfile(pgpPublicKey.getUserIDs().next(), pgpPublicKey.getKeyID(), new ProfileFingerprint(pgpPublicKey.getFingerprint()), pgpPublicKey.getEncoded()); + return Profile.createProfile(pgpPublicKey.getUserIDs().next(), pgpPublicKey.getKeyID(), pgpPublicKey.getCreationTime().toInstant(), new ProfileFingerprint(pgpPublicKey.getFingerprint()), pgpPublicKey.getEncoded()); } catch (IOException e) { diff --git a/app/src/main/java/io/xeres/app/xrs/service/identity/IdentityRsService.java b/app/src/main/java/io/xeres/app/xrs/service/identity/IdentityRsService.java index 07dfdd1be..d45923b7a 100644 --- a/app/src/main/java/io/xeres/app/xrs/service/identity/IdentityRsService.java +++ b/app/src/main/java/io/xeres/app/xrs/service/identity/IdentityRsService.java @@ -22,17 +22,20 @@ import io.xeres.app.crypto.hash.sha1.Sha1MessageDigest; import io.xeres.app.crypto.pgp.PGP; import io.xeres.app.crypto.rsa.RSA; +import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.gxs.GxsCircleType; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.app.database.model.gxs.GxsPrivacyFlags; +import io.xeres.app.database.model.profile.Profile; import io.xeres.app.database.repository.GxsIdentityRepository; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.ProfileService; import io.xeres.app.service.ResourceCreationState; import io.xeres.app.service.SettingsService; +import io.xeres.app.service.notification.contact.ContactNotificationService; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.RsServiceType; @@ -44,10 +47,12 @@ import io.xeres.common.dto.identity.IdentityConstants; import io.xeres.common.id.*; import io.xeres.common.identity.Type; +import io.xeres.common.util.ExecutorUtils; import jakarta.persistence.EntityNotFoundException; import net.coobird.thumbnailator.Thumbnails; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPSecretKey; +import org.springframework.data.domain.Limit; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -56,10 +61,14 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.security.InvalidKeyException; import java.security.KeyPair; +import java.security.SignatureException; +import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; +import java.util.concurrent.ScheduledExecutorService; import java.util.stream.Collectors; import static io.xeres.app.service.ResourceCreationState.*; @@ -74,18 +83,39 @@ public class IdentityRsService extends GxsRsService pendingIdentities = new ArrayDeque<>(PENDING_IDENTITIES_MAX); + private Instant lastFullQuery = Instant.EPOCH; + private final GxsIdentityRepository gxsIdentityRepository; private final SettingsService settingsService; private final ProfileService profileService; private final GxsUpdateService gxsUpdateService; + private final ContactNotificationService contactNotificationService; - public IdentityRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, GxsTransactionManager gxsTransactionManager, GxsIdentityRepository gxsIdentityRepository, SettingsService settingsService, ProfileService profileService, DatabaseSessionManager databaseSessionManager, IdentityManager identityManager, GxsUpdateService gxsUpdateService) + private enum ValidationResult + { + VALID, + INVALID, + NOT_FOUND + } + + public IdentityRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, GxsTransactionManager gxsTransactionManager, DatabaseSessionManager databaseSessionManager, GxsIdentityRepository gxsIdentityRepository, SettingsService settingsService, ProfileService profileService, IdentityManager identityManager, GxsUpdateService gxsUpdateService, ContactNotificationService contactNotificationService) { super(rsServiceRegistry, peerConnectionManager, gxsTransactionManager, databaseSessionManager, identityManager, gxsUpdateService); + this.databaseSessionManager = databaseSessionManager; this.gxsIdentityRepository = gxsIdentityRepository; this.settingsService = settingsService; this.profileService = profileService; this.gxsUpdateService = gxsUpdateService; + this.contactNotificationService = contactNotificationService; } @Override @@ -104,6 +134,114 @@ protected AuthenticationRequirements getAuthenticationRequirements() .build(); } + @Override + public void initialize() + { + super.initialize(); + + executorService = ExecutorUtils.createFixedRateExecutor(this::checkForProfileValidation, + getInitPriority().getMaxTime() + PENDING_VALIDATION_START.toSeconds(), + PENDING_VALIDATION_DELAY.toSeconds()); + } + + @Override + public void cleanup() + { + super.cleanup(); + ExecutorUtils.cleanupExecutor(executorService); + } + + private void checkForProfileValidation() + { + var identity = pendingIdentities.poll(); + if (identity == null) + { + // Search for identities not validated yet + var now = Instant.now(); + if (lastFullQuery.isBefore(now)) + { + try (var ignored = new DatabaseSession(databaseSessionManager)) + { + pendingIdentities.addAll(gxsIdentityRepository.findAllByNextValidationNotNullAndNextValidationBeforeOrderByNextValidationDesc(Instant.now(), Limit.of(PENDING_IDENTITIES_MAX))); + lastFullQuery = now.plus(PENDING_VALIDATION_FULL_QUERY_DELAY); + } + } + } + else + { + var identityServiceStorage = new IdentityServiceStorage(identity.getServiceString()); // We allow wrong service strings + + try (var ignored = new DatabaseSession(databaseSessionManager)) + { + switch (validate(identity, identityServiceStorage)) + { + case VALID -> + { + identityServiceStorage.updateIdScore(true, true); + identity.setNextValidation(null); + identity.setServiceString(identityServiceStorage.out()); + linkWithProfileIfFound(identity, identityServiceStorage.getPgpIdentifier()); + gxsIdentityRepository.save(identity); + } + case INVALID -> + { + gxsIdentityRepository.delete(identity); + contactNotificationService.removeIdentities(List.of(identity)); // This might be re-added immediately by discovery if it's on a friend. RS has the same problem + } + case NOT_FOUND -> + { + identityServiceStorage.updateIdScore(true, false); + identity.setNextValidation(identityServiceStorage.computeNextValidationAttempt()); + identity.setServiceString(identityServiceStorage.out()); + gxsIdentityRepository.save(identity); + } + } + } + } + } + + private ValidationResult validate(IdentityGroupItem identity, IdentityServiceStorage identityServiceStorage) + { + var pgpId = PGP.getIssuer(identity.getProfileSignature()); + if (pgpId == 0) + { + log.error("Found anonymous signature. Brute forcing it is not supported."); + return ValidationResult.INVALID; + } + identityServiceStorage.setPgpIdentifier(pgpId); + + var profile = profileService.findProfileByPgpIdentifier(pgpId).orElse(null); + if (profile == null) + { + log.debug("PGP profile not found for identity {}, retrying later", identity); + return ValidationResult.NOT_FOUND; + } + + var computedHash = makeProfileHash(identity.getGxsId(), profile.getProfileFingerprint()); + if (!identity.getProfileHash().equals(computedHash)) + { + log.error("Wrong profile hash for identity {}", identity); + return ValidationResult.INVALID; + } + + try + { + PGP.verify(PGP.getPGPPublicKey(profile.getPgpPublicKeyData()), identity.getProfileSignature(), new ByteArrayInputStream(computedHash.getBytes())); + log.debug("Successful PGP profile validation for identity {}", identity); + } + catch (IOException | SignatureException | PGPException | InvalidKeyException e) + { + log.error("Profile signature verification failed for identity {}: {}", identity, e.getMessage()); + return ValidationResult.INVALID; + } + return ValidationResult.VALID; + } + + private void linkWithProfileIfFound(IdentityGroupItem identity, long pgpId) + { + profileService.findProfileByPgpIdentifier(pgpId).ifPresent(identity::setProfile); + } + @Transactional @Override public void handleItem(PeerConnection sender, Item item) @@ -144,13 +282,17 @@ protected boolean onGroupReceived(IdentityGroupItem identityGroupItem) log.debug("Saving id {}", identityGroupItem.getGxsId()); // XXX: important! there should be some checks to make sure there's no malicious overwrite (probably a simple validation should do as id == fingerprint of key) identityGroupItem.setSubscribed(true); + if (identityGroupItem.getDiffusionFlags().contains(GxsPrivacyFlags.SIGNED_ID)) + { + identityGroupItem.setNextValidation(Instant.now()); + } return true; } @Override protected void onGroupsSaved(List items) { - // XXX: notify? + contactNotificationService.addIdentities(items); } @Override @@ -233,14 +375,14 @@ private long createOwnIdentity(IdentityGroupItem gxsIdGroupItem, boolean signed) if (signed) { var ownProfile = profileService.getOwnProfile(); - var hash = makeProfileHash(gxsIdGroupItem.getGxsId(), ownProfile.getProfileFingerprint()); - gxsIdGroupItem.setProfileHash(hash); - gxsIdGroupItem.setProfileSignature(makeProfileSignature(PGP.getPGPSecretKey(settingsService.getSecretProfileKey()), hash)); + computeHashAndSignature(gxsIdGroupItem, ownProfile); + gxsIdGroupItem.setProfile(ownProfile); // This is because of some backward compatibility, ideally it should be PUBLIC | REAL_ID // PRIVATE is equal to REAL_ID_deprecated gxsIdGroupItem.setDiffusionFlags(EnumSet.of(GxsPrivacyFlags.PRIVATE, GxsPrivacyFlags.SIGNED_ID)); - gxsIdGroupItem.setServiceString(String.format("v2 {P:K:1 I:%s}{T:F:0 P:0 T:0}{R:5 5 0 0}", Id.toString(ownProfile.getPgpIdentifier()))); + var identityServiceStorage = new IdentityServiceStorage(ownProfile.getPgpIdentifier()); + gxsIdGroupItem.setServiceString(identityServiceStorage.out()); } else { @@ -253,6 +395,31 @@ private long createOwnIdentity(IdentityGroupItem gxsIdGroupItem, boolean signed) return saveIdentity(gxsIdGroupItem, true).getId(); } + /** + * Fixes a profile signature. Xeres used to generate bugged signatures because of a mistake (upper case GxsId instead of lowercase). + * While RS will apparently accept them normally, Xeres will delete them. + */ + @Transactional + public void fixOwnProfile() throws PGPException, IOException + { + if (!profileService.hasOwnProfile() || hasOwnIdentity()) + { + return; // Nothing to do. There's no profile/identity yet. + } + var ownProfile = profileService.getOwnProfile(); + var ownIdentity = getOwnIdentity(); + ownIdentity.setProfile(ownProfile); + computeHashAndSignature(ownIdentity, ownProfile); + saveIdentity(ownIdentity, true); + } + + private void computeHashAndSignature(IdentityGroupItem gxsIdGroupItem, Profile profile) throws PGPException, IOException + { + var hash = makeProfileHash(gxsIdGroupItem.getGxsId(), profile.getProfileFingerprint()); + gxsIdGroupItem.setProfileHash(hash); + gxsIdGroupItem.setProfileSignature(makeProfileSignature(PGP.getPGPSecretKey(settingsService.getSecretProfileKey()), hash)); + } + public IdentityGroupItem getOwnIdentity() // XXX: temporary, we'll have several identities later { return gxsIdentityRepository.findById(IdentityConstants.OWN_IDENTITY_ID).orElseThrow(() -> new IllegalStateException("Missing own gxsId")); @@ -368,9 +535,9 @@ public void deleteOwnIdentityImage(long id) saveIdentity(identity, true); } - private static Sha1Sum makeProfileHash(GxsId gxsId, ProfileFingerprint fingerprint) + static Sha1Sum makeProfileHash(GxsId gxsId, ProfileFingerprint fingerprint) { - var gxsIdAsciiUpper = Id.toAsciiBytesUpperCase(gxsId); + var gxsIdAsciiUpper = Id.toAsciiBytes(gxsId); var md = new Sha1MessageDigest(); md.update(gxsIdAsciiUpper); diff --git a/app/src/main/java/io/xeres/app/xrs/service/identity/IdentityServiceStorage.java b/app/src/main/java/io/xeres/app/xrs/service/identity/IdentityServiceStorage.java index 7fe3b2845..7c8da55b5 100644 --- a/app/src/main/java/io/xeres/app/xrs/service/identity/IdentityServiceStorage.java +++ b/app/src/main/java/io/xeres/app/xrs/service/identity/IdentityServiceStorage.java @@ -19,20 +19,22 @@ package io.xeres.app.xrs.service.identity; +import java.time.Instant; import java.util.regex.Pattern; public class IdentityServiceStorage { private static final Pattern SERVICE_STRING = Pattern.compile("^v2 \\{P:(.{1,1024}?)}\\{T:(.{1,1024}?)}\\{R:(.{1,1024}?)}$"); - private Pgp pgp; - private Recognition recognition; - private Reputation reputation; + private final Pgp pgp = new Pgp(); + private final Recognition recognition = new Recognition(); + private final Reputation reputation = new Reputation(); private boolean success; public IdentityServiceStorage(long pgpIdentifier) { + pgp.setPgpIdentifier(pgpIdentifier); } public IdentityServiceStorage(String storage) @@ -49,19 +51,19 @@ private boolean in(String storage) return false; } - pgp = new Pgp(matcher.group(1)); + pgp.load(matcher.group(1)); if (!pgp.isSuccessful()) { return false; } - recognition = new Recognition(matcher.group(2)); + recognition.load(matcher.group(2)); if (!recognition.isSuccessful()) { return false; } - reputation = new Reputation(matcher.group(3)); + reputation.load(matcher.group(3)); if (!reputation.isSuccessful()) { return false; @@ -71,21 +73,31 @@ private boolean in(String storage) public String out() { - - return "v2 " + "{P:" + - pgp.out() + - "}" + - "{T:" + - recognition.out() + - "}" + - "{R:" + - reputation.out() + - "}"; + return String.format("v2 {P:%s}{T:%s}{R:%s}", pgp.out(), recognition.out(), reputation.out()); } - public boolean isSuccess() + public boolean isSuccessful() { return success; } + public long getPgpIdentifier() + { + return pgp.getPgpIdentifier(); + } + + public void setPgpIdentifier(long pgpIdentifier) + { + pgp.setPgpIdentifier(pgpIdentifier); + } + + public void updateIdScore(boolean pgpLinked, boolean pgpKnown) + { + reputation.updateIdScore(pgpLinked, pgpKnown); + } + + public Instant computeNextValidationAttempt() + { + return pgp.computeNextValidationAttempt(); + } } diff --git a/app/src/main/java/io/xeres/app/xrs/service/identity/Pgp.java b/app/src/main/java/io/xeres/app/xrs/service/identity/Pgp.java index 761c3e6e8..d97584c09 100644 --- a/app/src/main/java/io/xeres/app/xrs/service/identity/Pgp.java +++ b/app/src/main/java/io/xeres/app/xrs/service/identity/Pgp.java @@ -21,6 +21,7 @@ import io.xeres.common.id.Id; +import java.time.Duration; import java.time.Instant; import java.util.regex.Pattern; @@ -38,15 +39,14 @@ class Pgp private boolean success; - public Pgp(long pgpIdentifier) + public Pgp() { - this.pgpIdentifier = pgpIdentifier; - validated = true; } - public Pgp(String input) + public boolean load(String input) { success = in(input); + return success; } private boolean in(String input) @@ -108,4 +108,22 @@ public boolean isSuccessful() { return success; } + + public long getPgpIdentifier() + { + return pgpIdentifier; + } + + public void setPgpIdentifier(long pgpIdentifier) + { + this.pgpIdentifier = pgpIdentifier; + validated = true; + } + + public Instant computeNextValidationAttempt() + { + checkAttempt++; + lastCheck = Instant.now(); + return lastCheck.plus(Duration.ofDays(Math.min(checkAttempt, 30))); + } } diff --git a/app/src/main/java/io/xeres/app/xrs/service/identity/Recognition.java b/app/src/main/java/io/xeres/app/xrs/service/identity/Recognition.java index 6e708ea89..6a6f63465 100644 --- a/app/src/main/java/io/xeres/app/xrs/service/identity/Recognition.java +++ b/app/src/main/java/io/xeres/app/xrs/service/identity/Recognition.java @@ -32,16 +32,17 @@ class Recognition private boolean success; - public Recognition(int flags, Instant publish, Instant lastCheck) + public Recognition() { - this.flags = flags; - this.publish = publish; - this.lastCheck = lastCheck; + flags = 0; + publish = Instant.EPOCH; + lastCheck = Instant.EPOCH; } - public Recognition(String input) + public boolean load(String input) { success = in(input); + return success; } private boolean in(String input) @@ -59,12 +60,7 @@ private boolean in(String input) public String out() { - return "F:" + - flags + - " P:" + - publish.getEpochSecond() + - " T:" + - lastCheck.getEpochSecond(); + return String.format("F:%d P:%d T:%d", flags, publish.getEpochSecond(), lastCheck.getEpochSecond()); } public boolean isSuccessful() diff --git a/app/src/main/java/io/xeres/app/xrs/service/identity/Reputation.java b/app/src/main/java/io/xeres/app/xrs/service/identity/Reputation.java index 2634298d6..f3083d4a4 100644 --- a/app/src/main/java/io/xeres/app/xrs/service/identity/Reputation.java +++ b/app/src/main/java/io/xeres/app/xrs/service/identity/Reputation.java @@ -25,6 +25,10 @@ class Reputation { private static final Pattern REPUTATION_PATTERN = Pattern.compile("^(-?\\d{1,10}) (-?\\d{1,10}) (-?\\d{1,10}) (-?\\d{1,10})$"); + private static final int PGP_KNOWN_SCORE = 50; + private static final int PGP_UNKNOWN_SCORE = 20; + private static final int ANON_SCORE = 5; + private int overallScore; private int idScore; private int ownOpinion; @@ -32,17 +36,18 @@ class Reputation private boolean success; - public Reputation(int overallScore, int idScore, int ownOpinion, int peerOpinion) + public Reputation() { - this.overallScore = overallScore; - this.idScore = idScore; - this.ownOpinion = ownOpinion; - this.peerOpinion = peerOpinion; + overallScore = 5; + idScore = 5; + ownOpinion = 0; + peerOpinion = 0; } - public Reputation(String input) + public boolean load(String input) { success = in(input); + return success; } private boolean in(String input) @@ -68,4 +73,24 @@ public boolean isSuccessful() { return success; } + + public void updateIdScore(boolean pgpLinked, boolean pgpKnown) + { + if (pgpLinked) + { + if (pgpKnown) + { + idScore = PGP_KNOWN_SCORE; + } + else + { + idScore = PGP_UNKNOWN_SCORE; + } + } + else + { + idScore = ANON_SCORE; + } + overallScore = idScore + ownOpinion + peerOpinion; + } } \ No newline at end of file diff --git a/app/src/main/java/io/xeres/app/xrs/service/identity/item/IdentityGroupItem.java b/app/src/main/java/io/xeres/app/xrs/service/identity/item/IdentityGroupItem.java index 61a62c4d8..f284d7ca9 100644 --- a/app/src/main/java/io/xeres/app/xrs/service/identity/item/IdentityGroupItem.java +++ b/app/src/main/java/io/xeres/app/xrs/service/identity/item/IdentityGroupItem.java @@ -22,6 +22,7 @@ import io.netty.buffer.ByteBuf; import io.xeres.app.database.converter.IdentityTypeConverter; import io.xeres.app.database.model.gxs.GxsGroupItem; +import io.xeres.app.database.model.profile.Profile; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.TlvType; import io.xeres.common.id.GxsId; @@ -30,6 +31,7 @@ import jakarta.persistence.*; import org.apache.commons.lang3.ArrayUtils; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -42,11 +44,17 @@ public class IdentityGroupItem extends GxsGroupItem @Transient public static final IdentityGroupItem EMPTY = new IdentityGroupItem(); + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "profile_id") + private Profile profile; + @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "profile_hash")) private Sha1Sum profileHash; // hash of the gxsId + public key private byte[] profileSignature; + private Instant nextValidation; + @Transient private List recognitionTags = new ArrayList<>(); // not used (but serialized) @@ -75,6 +83,16 @@ public int getSubType() return 2; } + public Profile getProfile() + { + return profile; + } + + public void setProfile(Profile profile) + { + this.profile = profile; + } + public Sha1Sum getProfileHash() { return profileHash; @@ -95,6 +113,16 @@ public void setProfileSignature(byte[] profileSignature) this.profileSignature = ArrayUtils.isNotEmpty(profileSignature) ? profileSignature : null; } + public Instant getNextValidation() + { + return nextValidation; + } + + public void setNextValidation(Instant nextValidation) + { + this.nextValidation = nextValidation; + } + public boolean hasImage() { return image != null; diff --git a/app/src/main/resources/db/migration/V00_0_16_202410061715__AddProfileValidation.sql b/app/src/main/resources/db/migration/V00_0_16_202410061715__AddProfileValidation.sql new file mode 100644 index 000000000..a7574c0d9 --- /dev/null +++ b/app/src/main/resources/db/migration/V00_0_16_202410061715__AddProfileValidation.sql @@ -0,0 +1,7 @@ +-- +-- Add profile validation to identities +-- +ALTER TABLE identity_group ADD COLUMN profile_id BIGINT DEFAULT NULL AFTER id; +ALTER TABLE identity_group ADD COLUMN next_validation TIMESTAMP(9) DEFAULT NULL AFTER profile_signature; + +UPDATE identity_group SET next_validation = PARSEDATETIME('1970-01-01 00:00:00', 'yyyy-MM-dd hh:mm:ss') WHERE id != 1 AND profile_signature IS NOT NULL \ No newline at end of file diff --git a/app/src/main/resources/db/migration/V00_0_17_202410112205__AddProfileCreation.sql b/app/src/main/resources/db/migration/V00_0_17_202410112205__AddProfileCreation.sql new file mode 100644 index 000000000..f19f7a9e0 --- /dev/null +++ b/app/src/main/resources/db/migration/V00_0_17_202410112205__AddProfileCreation.sql @@ -0,0 +1,4 @@ +-- +-- Add profile creation time +-- +ALTER TABLE profile ADD COLUMN created TIMESTAMP(9) DEFAULT NULL AFTER pgp_identifier; diff --git a/app/src/test/java/io/xeres/app/api/controller/notification/NotificationControllerTest.java b/app/src/test/java/io/xeres/app/api/controller/notification/NotificationControllerTest.java index 078d42028..7c9bdbe08 100644 --- a/app/src/test/java/io/xeres/app/api/controller/notification/NotificationControllerTest.java +++ b/app/src/test/java/io/xeres/app/api/controller/notification/NotificationControllerTest.java @@ -20,6 +20,7 @@ package io.xeres.app.api.controller.notification; import io.xeres.app.api.controller.AbstractControllerTest; +import io.xeres.app.service.notification.contact.ContactNotificationService; import io.xeres.app.service.notification.file.FileNotificationService; import io.xeres.app.service.notification.file.FileSearchNotificationService; import io.xeres.app.service.notification.forum.ForumNotificationService; @@ -55,6 +56,9 @@ class NotificationControllerTest extends AbstractControllerTest @MockBean private FileSearchNotificationService fileSearchNotificationService; + @MockBean + private ContactNotificationService contactNotificationService; + @Autowired public MockMvc mvc; diff --git a/app/src/test/java/io/xeres/app/api/controller/profile/ProfileControllerTest.java b/app/src/test/java/io/xeres/app/api/controller/profile/ProfileControllerTest.java index 19305e89a..2ef55095b 100644 --- a/app/src/test/java/io/xeres/app/api/controller/profile/ProfileControllerTest.java +++ b/app/src/test/java/io/xeres/app/api/controller/profile/ProfileControllerTest.java @@ -166,7 +166,7 @@ void CreateProfile_ShortInvite_WithTrustAndConnectionIndex_Success() throws Exce var profileRequest = new RsIdRequest(RSIdFakes.createShortInvite().getArmored()); when(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected); - when(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(Optional.of(expected)); + when(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(expected); mvc.perform(postJson(BASE_URL + "?trust=FULL&connectionIndex=1", profileRequest)) .andExpect(status().isCreated()) @@ -184,7 +184,7 @@ void CreateProfile_ShortInvite_WithTrustInMixedCaseAndConnectionIndex_Success() var profileRequest = new RsIdRequest(RSIdFakes.createShortInvite().getArmored()); when(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected); - when(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(Optional.of(expected)); + when(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(expected); mvc.perform(postJson(BASE_URL + "?trust=Full&connectionIndex=1", profileRequest)) .andExpect(status().isCreated()) @@ -201,7 +201,7 @@ void CreateProfile_ShortInvite_Success() throws Exception var profileRequest = new RsIdRequest(RSIdFakes.createShortInvite().getArmored()); when(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected); - when(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(Optional.of(expected)); + when(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(expected); mvc.perform(postJson(BASE_URL, profileRequest)) .andExpect(status().isCreated()) @@ -217,7 +217,7 @@ void CreateProfile_RsCertificate_Success() throws Exception var profileRequest = new RsIdRequest(RSIdFakes.createRsCertificate(expected).getArmored()); when(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected); - when(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(Optional.of(expected)); + when(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(expected); mvc.perform(postJson(BASE_URL, profileRequest)) .andExpect(status().isCreated()) diff --git a/app/src/test/java/io/xeres/app/database/model/profile/ProfileFakes.java b/app/src/test/java/io/xeres/app/database/model/profile/ProfileFakes.java index a2e7267fc..5c3bf158d 100644 --- a/app/src/test/java/io/xeres/app/database/model/profile/ProfileFakes.java +++ b/app/src/test/java/io/xeres/app/database/model/profile/ProfileFakes.java @@ -22,6 +22,7 @@ import io.xeres.common.id.ProfileFingerprint; import io.xeres.testutils.StringFakes; +import java.time.Instant; import java.util.concurrent.ThreadLocalRandom; import static io.xeres.common.dto.profile.ProfileConstants.OWN_PROFILE_ID; @@ -52,12 +53,12 @@ public static Profile createProfile(String name, long pgpIdentifier) public static Profile createProfile(String name, long pgpIdentifier, byte[] pgpFingerprint, byte[] data) { - return new Profile(getUniqueId(), name, pgpIdentifier, new ProfileFingerprint(pgpFingerprint), data); + return new Profile(getUniqueId(), name, pgpIdentifier, Instant.now(), new ProfileFingerprint(pgpFingerprint), data); } public static Profile createProfile(String name, long pgpIdentifier, ProfileFingerprint profileFingerprint, byte[] data) { - return new Profile(getUniqueId(), name, pgpIdentifier, profileFingerprint, data); + return new Profile(getUniqueId(), name, pgpIdentifier, Instant.now(), profileFingerprint, data); } private static byte[] getRandomArray(int size) diff --git a/app/src/test/java/io/xeres/app/database/model/profile/ProfileMapperTest.java b/app/src/test/java/io/xeres/app/database/model/profile/ProfileMapperTest.java index d1f1c642d..7b394f385 100644 --- a/app/src/test/java/io/xeres/app/database/model/profile/ProfileMapperTest.java +++ b/app/src/test/java/io/xeres/app/database/model/profile/ProfileMapperTest.java @@ -25,6 +25,8 @@ import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; +import java.time.Instant; + import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -70,6 +72,7 @@ void fromDTO_Success() 1L, "prout", "2", + Instant.now(), new byte[20], new byte[4], true, diff --git a/app/src/test/java/io/xeres/app/service/LocationServiceTest.java b/app/src/test/java/io/xeres/app/service/LocationServiceTest.java index df0d90a6d..aa79b2381 100644 --- a/app/src/test/java/io/xeres/app/service/LocationServiceTest.java +++ b/app/src/test/java/io/xeres/app/service/LocationServiceTest.java @@ -27,6 +27,7 @@ import io.xeres.app.database.model.profile.Profile; import io.xeres.app.database.model.profile.ProfileFakes; import io.xeres.app.database.repository.LocationRepository; +import io.xeres.common.id.ProfileFingerprint; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPSecretKey; @@ -81,13 +82,13 @@ class LocationServiceTest private static Profile ownProfile; @BeforeAll - static void setup() throws PGPException, IOException + static void setup() throws PGPException { Security.addProvider(new BouncyCastleProvider()); pgpSecretKey = PGP.generateSecretKey("test", "", 512); keyPair = RSA.generateKeys(512); - ownProfile = Profile.createProfile("test", pgpSecretKey.getKeyID(), pgpSecretKey.getPublicKey().getFingerprint(), pgpSecretKey.getPublicKey().getEncoded()); + ownProfile = Profile.createProfile("test", pgpSecretKey.getKeyID(), pgpSecretKey.getPublicKey().getCreationTime().toInstant(), new ProfileFingerprint(pgpSecretKey.getPublicKey().getFingerprint()), pgpSecretKey.getPublicKey()); } @Test diff --git a/app/src/test/java/io/xeres/app/service/ProfileServiceTest.java b/app/src/test/java/io/xeres/app/service/ProfileServiceTest.java index 0bfa77b80..a456e3b3f 100644 --- a/app/src/test/java/io/xeres/app/service/ProfileServiceTest.java +++ b/app/src/test/java/io/xeres/app/service/ProfileServiceTest.java @@ -23,6 +23,7 @@ import io.xeres.app.database.model.profile.Profile; import io.xeres.app.database.model.profile.ProfileFakes; import io.xeres.app.database.repository.ProfileRepository; +import io.xeres.app.service.notification.contact.ContactNotificationService; import io.xeres.common.dto.profile.ProfileConstants; import io.xeres.common.id.ProfileFingerprint; import org.bouncycastle.jce.provider.BouncyCastleProvider; @@ -51,6 +52,9 @@ class ProfileServiceTest @Mock private ProfileRepository profileRepository; + @Mock + private ContactNotificationService contactNotificationService; + @InjectMocks private ProfileService profileService; @@ -124,7 +128,7 @@ void CreateOrUpdateProfile_Update_Success() when(profileRepository.findByProfileFingerprint(any(ProfileFingerprint.class))).thenReturn(Optional.of(first)); when(profileRepository.save(any(Profile.class))).thenAnswer(mock -> mock.getArguments()[0]); - var updated = profileService.createOrUpdateProfile(second).orElseThrow(); + var updated = profileService.createOrUpdateProfile(second); assertEquals(2, updated.getLocations().size()); diff --git a/app/src/test/java/io/xeres/app/xrs/service/gxs/GxsSignatureTest.java b/app/src/test/java/io/xeres/app/xrs/service/gxs/GxsSignatureTest.java index 783ea068f..521467be6 100644 --- a/app/src/test/java/io/xeres/app/xrs/service/gxs/GxsSignatureTest.java +++ b/app/src/test/java/io/xeres/app/xrs/service/gxs/GxsSignatureTest.java @@ -58,13 +58,13 @@ void Create_And_Verify_Success() private RawItem serializeItem(Item item) { - item.setOutgoing(Unpooled.buffer().alloc(), new IdentityRsService(null, null, null, null, null, null, null, null, null)); + item.setOutgoing(Unpooled.buffer().alloc(), new IdentityRsService(null, null, null, null, null, null, null, null, null, null)); return item.serializeItem(EnumSet.noneOf(SerializationFlags.class)); } private byte[] serializeItemForSignature(Item item) { - item.setOutgoing(Unpooled.buffer().alloc(), new IdentityRsService(null, null, null, null, null, null, null, null, null)); + item.setOutgoing(Unpooled.buffer().alloc(), new IdentityRsService(null, null, null, null, null, null, null, null, null, null)); var buf = item.serializeItem(EnumSet.of(SerializationFlags.SIGNATURE)).getBuffer(); var data = new byte[buf.writerIndex() - HEADER_SIZE]; buf.getBytes(HEADER_SIZE, data); diff --git a/app/src/test/java/io/xeres/app/xrs/service/identity/IdentityRsServiceTest.java b/app/src/test/java/io/xeres/app/xrs/service/identity/IdentityRsServiceTest.java index 2ff0ded58..009ccba8a 100644 --- a/app/src/test/java/io/xeres/app/xrs/service/identity/IdentityRsServiceTest.java +++ b/app/src/test/java/io/xeres/app/xrs/service/identity/IdentityRsServiceTest.java @@ -28,6 +28,8 @@ import io.xeres.app.service.SettingsService; import io.xeres.app.xrs.service.gxs.GxsUpdateService; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; +import io.xeres.common.id.GxsId; +import io.xeres.common.id.Id; import io.xeres.common.id.ProfileFingerprint; import jakarta.persistence.EntityNotFoundException; import org.bouncycastle.jce.provider.BouncyCastleProvider; @@ -197,4 +199,11 @@ void DeleteIdentityImage_NotOwn_Error() assertThrows(EntityNotFoundException.class, () -> identityRsService.deleteOwnIdentityImage(id)); } + + @Test + void MakeProfileHash_Success() + { + var computedHash = IdentityRsService.makeProfileHash(GxsId.fromString("bb3851c00134a29f921cb3643a4525a9"), new ProfileFingerprint(Id.toBytes("C984CC1237437B5983A2031070DC1676FA60F825"))); + assertEquals("778db3511ba29027dd85f324c58717d05c4e3f30", computedHash.toString()); + } } diff --git a/app/src/test/java/io/xeres/app/xrs/service/identity/IdentityServiceStorageTest.java b/app/src/test/java/io/xeres/app/xrs/service/identity/IdentityServiceStorageTest.java index e6f8f4c08..4bcff4dd1 100644 --- a/app/src/test/java/io/xeres/app/xrs/service/identity/IdentityServiceStorageTest.java +++ b/app/src/test/java/io/xeres/app/xrs/service/identity/IdentityServiceStorageTest.java @@ -31,7 +31,7 @@ void ParseIdentityString_Success() var input = "v2 {P:K:1 I:133E525084DE5D4D}{T:F:4096 P:1614029822 T:1614029841}{R:50 50 0 0}"; var identityServiceStorage = new IdentityServiceStorage(input); - assertTrue(identityServiceStorage.isSuccess()); + assertTrue(identityServiceStorage.isSuccessful()); } @Test @@ -49,7 +49,7 @@ void ParseIdentityString_Negative_Rating_Success() var input = "v2 {P:K:1 I:133E525084DE5D4D}{T:F:4096 P:1614029822 T:1614029841}{R:50 -50 0 0}"; var identityServiceStorage = new IdentityServiceStorage(input); - assertTrue(identityServiceStorage.isSuccess()); + assertTrue(identityServiceStorage.isSuccessful()); } @Test @@ -58,7 +58,7 @@ void ParseIdentityString_WrongVersion_Failure() var input = "v1 {P:K:1 I:133E525084DE5D4D}{T:F:4096 P:1614029822 T:1614029841}{R:50 50 0 0}"; var identityServiceStorage = new IdentityServiceStorage(input); - assertFalse(identityServiceStorage.isSuccess()); + assertFalse(identityServiceStorage.isSuccessful()); } @Test @@ -67,6 +67,20 @@ void ParseIdentityString_NegativePublish_Failure() var input = "v2 {P:K:1 I:133E525084DE5D4D}{T:F:4096 P:-1 T:1614029841}{R:50 50 0 0}"; var identityServiceStorage = new IdentityServiceStorage(input); - assertFalse(identityServiceStorage.isSuccess()); + assertFalse(identityServiceStorage.isSuccessful()); + } + + @Test + void DefaultIdentity_Success() + { + var identityServiceStorage = new IdentityServiceStorage(0x12345678abcdefdaL); + assertEquals("v2 {P:K:1 I:12345678ABCDEFDA}{T:F:0 P:0 T:0}{R:5 5 0 0}", identityServiceStorage.out()); + } + + @Test + void DefaultIdentity_ZeroPrefix_Success() + { + var identityServiceStorage = new IdentityServiceStorage(0x2345678abcdefdaL); + assertEquals("v2 {P:K:1 I:02345678ABCDEFDA}{T:F:0 P:0 T:0}{R:5 5 0 0}", identityServiceStorage.out()); } } \ No newline at end of file diff --git a/common/src/main/java/io/xeres/common/dto/profile/ProfileDTO.java b/common/src/main/java/io/xeres/common/dto/profile/ProfileDTO.java index 4f181ffd3..79b2580dd 100644 --- a/common/src/main/java/io/xeres/common/dto/profile/ProfileDTO.java +++ b/common/src/main/java/io/xeres/common/dto/profile/ProfileDTO.java @@ -27,6 +27,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -46,6 +47,8 @@ public record ProfileDTO( String pgpIdentifier, + Instant created, + @Size(min = ProfileFingerprint.LENGTH, max = ProfileFingerprint.LENGTH) @Schema(example = "nhgF6ITwm/LLqchhpwJ91KFfAxg=") byte[] pgpFingerprint, diff --git a/common/src/main/java/io/xeres/common/id/Id.java b/common/src/main/java/io/xeres/common/id/Id.java index 0a640e61c..bb2b84312 100644 --- a/common/src/main/java/io/xeres/common/id/Id.java +++ b/common/src/main/java/io/xeres/common/id/Id.java @@ -97,7 +97,7 @@ public static byte[] toBytes(String id) */ public static String toString(long id) { - return Long.toHexString(id).toUpperCase(Locale.ROOT); + return toStringLowerCase(id).toUpperCase(Locale.ROOT); } /** @@ -108,7 +108,7 @@ public static String toString(long id) */ public static String toStringLowerCase(long id) { - return Long.toHexString(id); + return HexFormat.of().toHexDigits(id, 16); } /** diff --git a/common/src/main/java/io/xeres/common/rest/notification/contact/AddContacts.java b/common/src/main/java/io/xeres/common/rest/notification/contact/AddContacts.java new file mode 100644 index 000000000..1be5200e5 --- /dev/null +++ b/common/src/main/java/io/xeres/common/rest/notification/contact/AddContacts.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 by David Gerber - https://zapek.com + * + * This file is part of Xeres. + * + * Xeres is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Xeres is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Xeres. If not, see . + */ + +package io.xeres.common.rest.notification.contact; + +import io.xeres.common.rest.contact.Contact; + +import java.util.List; + +/** + * Adds or update contacts. + * + * @param contacts the list of contacts + */ +public record AddContacts(List contacts) +{ +} diff --git a/common/src/main/java/io/xeres/common/rest/notification/contact/ContactNotification.java b/common/src/main/java/io/xeres/common/rest/notification/contact/ContactNotification.java new file mode 100644 index 000000000..a3426a7a6 --- /dev/null +++ b/common/src/main/java/io/xeres/common/rest/notification/contact/ContactNotification.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2024 by David Gerber - https://zapek.com + * + * This file is part of Xeres. + * + * Xeres is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Xeres is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Xeres. If not, see . + */ + +package io.xeres.common.rest.notification.contact; + +import io.xeres.common.rest.notification.Notification; + +public record ContactNotification(String id, Object action) implements Notification +{ +} diff --git a/common/src/main/java/io/xeres/common/rest/notification/contact/RemoveContacts.java b/common/src/main/java/io/xeres/common/rest/notification/contact/RemoveContacts.java new file mode 100644 index 000000000..4141e1551 --- /dev/null +++ b/common/src/main/java/io/xeres/common/rest/notification/contact/RemoveContacts.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 by David Gerber - https://zapek.com + * + * This file is part of Xeres. + * + * Xeres is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Xeres is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Xeres. If not, see . + */ + +package io.xeres.common.rest.notification.contact; + +import io.xeres.common.rest.contact.Contact; + +import java.util.List; + +public record RemoveContacts(List contacts) +{ +} diff --git a/common/src/test/java/io/xeres/common/id/IdTest.java b/common/src/test/java/io/xeres/common/id/IdTest.java index 88c60876c..ef2ad7d35 100644 --- a/common/src/test/java/io/xeres/common/id/IdTest.java +++ b/common/src/test/java/io/xeres/common/id/IdTest.java @@ -91,11 +91,41 @@ void ToBytes_FromString_Empty_Success() @Test void ToString_FromLong_Success() { - var id = 0x2344ab38L; + var id = 0x843303842344ab38L; var result = Id.toString(id); - assertEquals("2344AB38", result); + assertEquals("843303842344AB38", result); + } + + @Test + void ToString_FromLong_Negative_LowerCase_Success() + { + var id = 0xf43303842344ab38L; + + var result = Id.toStringLowerCase(id); + + assertEquals("f43303842344ab38", result); + } + + @Test + void ToString_FromLong_Negative_Success() + { + var id = 0xf43303842344ab38L; + + var result = Id.toString(id); + + assertEquals("F43303842344AB38", result); + } + + @Test + void ToString_FromLong_ZeroPrefix_Success() + { + var id = 0x0344ab38L; + + var result = Id.toString(id); + + assertEquals("000000000344AB38", result); } @Test diff --git a/common/src/testFixtures/java/io/xeres/common/dto/profile/ProfileDTOFakes.java b/common/src/testFixtures/java/io/xeres/common/dto/profile/ProfileDTOFakes.java index 1585dd559..ff6776820 100644 --- a/common/src/testFixtures/java/io/xeres/common/dto/profile/ProfileDTOFakes.java +++ b/common/src/testFixtures/java/io/xeres/common/dto/profile/ProfileDTOFakes.java @@ -26,6 +26,7 @@ import io.xeres.testutils.IdFakes; import io.xeres.testutils.StringFakes; +import java.time.Instant; import java.util.List; public final class ProfileDTOFakes @@ -37,6 +38,6 @@ private ProfileDTOFakes() public static ProfileDTO create() { - return new ProfileDTO(IdFakes.createLong(), StringFakes.createNickname(), Long.toString(IdFakes.createLong()), new byte[20], new byte[1], BooleanFakes.create(), EnumFakes.create(Trust.class), List.of(LocationDTOFakes.create())); + return new ProfileDTO(IdFakes.createLong(), StringFakes.createNickname(), Long.toString(IdFakes.createLong()), Instant.now(), new byte[20], new byte[1], BooleanFakes.create(), EnumFakes.create(Trust.class), List.of(LocationDTOFakes.create())); } } diff --git a/ui/src/main/java/io/xeres/ui/client/NotificationClient.java b/ui/src/main/java/io/xeres/ui/client/NotificationClient.java index ee86ce06a..4c772a524 100644 --- a/ui/src/main/java/io/xeres/ui/client/NotificationClient.java +++ b/ui/src/main/java/io/xeres/ui/client/NotificationClient.java @@ -20,6 +20,7 @@ package io.xeres.ui.client; import io.xeres.common.events.StartupEvent; +import io.xeres.common.rest.notification.contact.ContactNotification; import io.xeres.common.rest.notification.file.FileNotification; import io.xeres.common.rest.notification.file.FileSearchNotification; import io.xeres.common.rest.notification.forum.ForumNotification; @@ -93,4 +94,14 @@ public Flux> getFileSearchNotifications( { }); } + + public Flux> getContactNotifications() + { + return webClient.get() + .uri("/contact") + .retrieve() + .bodyToFlux(new ParameterizedTypeReference<>() + { + }); + } } diff --git a/ui/src/main/java/io/xeres/ui/controller/contact/ContactViewController.java b/ui/src/main/java/io/xeres/ui/controller/contact/ContactViewController.java index 8839b2aa4..32ee795f5 100644 --- a/ui/src/main/java/io/xeres/ui/controller/contact/ContactViewController.java +++ b/ui/src/main/java/io/xeres/ui/controller/contact/ContactViewController.java @@ -19,17 +19,18 @@ package io.xeres.ui.controller.contact; -import atlantafx.base.controls.Card; +import com.fasterxml.jackson.databind.ObjectMapper; import io.xeres.common.id.Id; import io.xeres.common.protocol.HostPort; import io.xeres.common.rest.contact.Contact; -import io.xeres.ui.client.ContactClient; -import io.xeres.ui.client.GeneralClient; -import io.xeres.ui.client.IdentityClient; -import io.xeres.ui.client.ProfileClient; +import io.xeres.common.rest.notification.contact.AddContacts; +import io.xeres.common.rest.notification.contact.RemoveContacts; +import io.xeres.ui.client.*; import io.xeres.ui.controller.Controller; import io.xeres.ui.custom.AsyncImageView; import io.xeres.ui.model.location.Location; +import io.xeres.ui.support.util.DateUtils; +import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; @@ -43,13 +44,20 @@ import javafx.scene.control.cell.PropertyValueFactory; import javafx.scene.layout.GridPane; import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; import net.rgielen.fxweaver.core.FxmlView; import org.kordamp.ikonli.javafx.FontIcon; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; +import reactor.core.Disposable; import java.io.IOException; import java.util.List; import java.util.Locale; +import java.util.Objects; import static io.xeres.common.rest.PathConfig.IDENTITIES_PATH; import static io.xeres.ui.support.util.DateUtils.DATE_TIME_DISPLAY; @@ -58,6 +66,15 @@ @FxmlView(value = "/view/contact/contactview.fxml") public class ContactViewController implements Controller { + private static final Logger log = LoggerFactory.getLogger(ContactViewController.class); + + private enum Information + { + PROFILE, + IDENTITY, + MERGED + } + @FXML private TableView contactTableView; @@ -86,7 +103,10 @@ public class ContactViewController implements Controller private Label typeLabel; @FXML - private Label updatedLabel; + private Label createdOrUpdated; + + @FXML + private Label createdLabel; @FXML private FontIcon contactIcon; @@ -98,7 +118,7 @@ public class ContactViewController implements Controller private Label trustLabel; @FXML - private Card detailsView; + private VBox detailsView; @FXML private GridPane profilePane; @@ -122,13 +142,19 @@ public class ContactViewController implements Controller private final GeneralClient generalClient; private final ProfileClient profileClient; private final IdentityClient identityClient; + private final NotificationClient notificationClient; + private final ObjectMapper objectMapper; - public ContactViewController(ContactClient contactClient, GeneralClient generalClient, ProfileClient profileClient, IdentityClient identityClient) + private Disposable notificationDisposable; + + public ContactViewController(ContactClient contactClient, GeneralClient generalClient, ProfileClient profileClient, IdentityClient identityClient, NotificationClient notificationClient, ObjectMapper objectMapper) { this.contactClient = contactClient; this.generalClient = generalClient; this.profileClient = profileClient; this.identityClient = identityClient; + this.notificationClient = notificationClient; + this.objectMapper = objectMapper; } @Override @@ -142,18 +168,6 @@ public void initialize() throws IOException var filteredList = new FilteredList<>(contactTableView.getItems()); - contactClient.getContacts().collectList() - .doOnSuccess(contacts -> Platform.runLater(() -> { - // Add all contacts - contactTableView.getItems().addAll(contacts); - - // Sort by name - contactTableView.getSortOrder().add(contactTableNameColumn); - contactTableNameColumn.setSortType(TableColumn.SortType.ASCENDING); - contactTableNameColumn.setSortable(true); - })) - .subscribe(); - searchTextField.textProperty().addListener((observable, oldValue, newValue) -> { if (newValue.isEmpty()) { @@ -189,6 +203,106 @@ public void initialize() throws IOException // Workaround for https://github.com/mkpaz/atlantafx/issues/31 contactIcon.iconSizeProperty() .addListener((observable, oldValue, newValue) -> contactIcon.setIconSize(128)); + + setupContactNotifications(); + + getContacts(); + } + + private void getContacts() + { + contactClient.getContacts().collectList() + .doOnSuccess(contacts -> Platform.runLater(() -> { + // Add all contacts + contactTableView.getItems().addAll(contacts); + + // Sort by name + contactTableView.getSortOrder().add(contactTableNameColumn); + contactTableNameColumn.setSortType(TableColumn.SortType.ASCENDING); + contactTableNameColumn.setSortable(true); + })) + .subscribe(); + } + + private void setupContactNotifications() + { + notificationDisposable = notificationClient.getContactNotifications() + .doOnError(UiUtils::showAlertError) + .doOnNext(sse -> Platform.runLater(() -> { + if (sse.data() != null) + { + var idName = Objects.requireNonNull(sse.id()); + + if (idName.equals(AddContacts.class.getSimpleName())) + { + var action = objectMapper.convertValue(sse.data().action(), AddContacts.class); + addContacts(action.contacts()); + } + else if (idName.equals(RemoveContacts.class.getSimpleName())) + { + var action = objectMapper.convertValue(sse.data().action(), RemoveContacts.class); + action.contacts().forEach(this::removeContact); + } + else + { + log.debug("Unknown contact notification"); + } + } + })) + .subscribe(); + } + + private void addContacts(List contacts) + { + var added = false; + + for (Contact contact : contacts) + { + if (addContact(contact)) + { + added = true; + } + } + + if (added) + { + contactTableView.sort(); + } + } + + private boolean addContact(Contact contact) + { + log.debug("Adding contact {}", contact); + // XXX: should we have a map to speed up all this? + if (contact.identityId() != 0L) + { + if (contactTableView.getItems().stream() + .noneMatch(existingContact -> existingContact.identityId() == contact.identityId())) + { + contactTableView.getItems().add(contact); + return true; + } + } + else if (contact.profileId() != 0L) + { + if (contactTableView.getItems().stream() + .noneMatch(existingContact -> existingContact.profileId() == contact.profileId() && existingContact.name().equals(contact.name()))) + { + contactTableView.getItems().add(contact); + return true; + } + } + else + { + throw new IllegalStateException("Empty contact (identity == 0L and profile == 0L). Shouldn't happen."); + } + return false; + } + + private void removeContact(Contact contact) + { + log.debug("Removing contact {}", contact); + contactTableView.getItems().removeIf(existingContact -> existingContact.identityId() == contact.identityId()); } private HostPort getConnectedAddress(Location location) @@ -229,7 +343,7 @@ private void changeSelectedContact(Contact contact) nameLabel.setText(null); idLabel.setText(null); typeLabel.setText(null); - updatedLabel.setText(null); + createdLabel.setText(null); contactImageView.setImage(null); contactImagePane.getChildren().getFirst().setVisible(true); profilePane.setVisible(false); @@ -239,40 +353,78 @@ private void changeSelectedContact(Contact contact) detailsView.setVisible(true); nameLabel.setText(contact.name()); - if (contact.profileId() != 0L) + if (contact.profileId() != 0L && contact.identityId() != 0L) { - profileClient.findById(contact.profileId()) - .doOnSuccess(profile -> Platform.runLater(() -> { - idLabel.setText(Id.toString(profile.getPgpIdentifier())); - updatedLabel.setText(null); // XXX: for now... - acceptedLabel.setText(profile.isAccepted() ? "yes" : "no"); - trustLabel.setText(profile.getTrust().toString()); - profilePane.setVisible(true); - showTableLocations(profile.getLocations()); - })) - .subscribe(); + contactImagePane.getChildren().getFirst().setVisible(false); + contactImageView.setUrl(IDENTITIES_PATH + "/" + contact.identityId() + "/image"); + typeLabel.setText("Contact linked to profile"); + fetchProfile(contact.profileId(), Information.MERGED); + fetchContact(contact.identityId(), Information.MERGED); + } + else if (contact.profileId() != 0L) + { contactImagePane.getChildren().getFirst().setVisible(true); contactImageView.setUrl(null); typeLabel.setText("Profile"); + + fetchProfile(contact.profileId(), Information.PROFILE); } else if (contact.identityId() != 0L) { profilePane.setVisible(false); - identityClient.findById(contact.identityId()) - .doOnSuccess(identity -> Platform.runLater(() -> { - idLabel.setText(Id.toString(identity.getGxsId())); - updatedLabel.setText(DATE_TIME_DISPLAY.format(identity.getUpdated())); - })) - .subscribe(); contactImagePane.getChildren().getFirst().setVisible(false); contactImageView.setUrl(IDENTITIES_PATH + "/" + contact.identityId() + "/image"); typeLabel.setText("Contact"); hideTableLocations(); + + fetchContact(contact.identityId(), Information.IDENTITY); } } + private void fetchProfile(long profileId, Information information) + { + profileClient.findById(profileId) + .doOnSuccess(profile -> Platform.runLater(() -> { + if (information == Information.PROFILE) + { + idLabel.setText(Id.toString(profile.getPgpIdentifier())); + } + if (information == Information.PROFILE || information == Information.MERGED) + { + createdOrUpdated.setText("Created"); + createdLabel.setText(profile.getCreated() != null ? DateUtils.DATE_TIME_DISPLAY.format(profile.getCreated()) : "unknown"); + if (information == Information.MERGED) + { + typeLabel.setText("Contact linked to profile " + Id.toString(profile.getPgpIdentifier())); + } + } + acceptedLabel.setText(profile.isAccepted() ? "yes" : "no"); + trustLabel.setText(profile.getTrust().toString()); + profilePane.setVisible(true); + showTableLocations(profile.getLocations()); + })) + .subscribe(); + } + + private void fetchContact(long identityId, Information information) + { + identityClient.findById(identityId) + .doOnSuccess(identity -> Platform.runLater(() -> { + if (information == Information.IDENTITY || information == Information.MERGED) + { + idLabel.setText(Id.toString(identity.getGxsId())); + } + if (information == Information.IDENTITY) + { + createdOrUpdated.setText("Updated"); + createdLabel.setText(DATE_TIME_DISPLAY.format(identity.getUpdated())); + } + })) + .subscribe(); + } + private void showTableLocations(List locations) { if (locations.isEmpty()) @@ -291,4 +443,13 @@ private void hideTableLocations() locationTableView.getItems().clear(); locationTableView.setVisible(false); } + + @EventListener + public void onApplicationEvent(ContextClosedEvent ignored) + { + if (notificationDisposable != null && !notificationDisposable.isDisposed()) + { + notificationDisposable.dispose(); + } + } } diff --git a/ui/src/main/java/io/xeres/ui/controller/forum/ForumViewController.java b/ui/src/main/java/io/xeres/ui/controller/forum/ForumViewController.java index 592aa9e96..5981c6ae5 100644 --- a/ui/src/main/java/io/xeres/ui/controller/forum/ForumViewController.java +++ b/ui/src/main/java/io/xeres/ui/controller/forum/ForumViewController.java @@ -373,7 +373,6 @@ else if (idName.equals(AddForumMessages.class.getSimpleName())) { log.debug("Unknown forum notification"); } - // XXX: add message, etc... but only if the group is already selected } })) .subscribe(); diff --git a/ui/src/main/java/io/xeres/ui/model/profile/Profile.java b/ui/src/main/java/io/xeres/ui/model/profile/Profile.java index ada91e648..79294064b 100644 --- a/ui/src/main/java/io/xeres/ui/model/profile/Profile.java +++ b/ui/src/main/java/io/xeres/ui/model/profile/Profile.java @@ -23,6 +23,7 @@ import io.xeres.common.pgp.Trust; import io.xeres.ui.model.location.Location; +import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -31,6 +32,7 @@ public class Profile private long id; private String name; private long pgpIdentifier; + private Instant created; private ProfileFingerprint profileFingerprint; private byte[] pgpPublicKeyData; private boolean accepted; @@ -67,6 +69,16 @@ public void setPgpIdentifier(long pgpIdentifier) this.pgpIdentifier = pgpIdentifier; } + public Instant getCreated() + { + return created; + } + + public void setCreated(Instant created) + { + this.created = created; + } + public ProfileFingerprint getProfileFingerprint() { return profileFingerprint; diff --git a/ui/src/main/java/io/xeres/ui/model/profile/ProfileMapper.java b/ui/src/main/java/io/xeres/ui/model/profile/ProfileMapper.java index 1a8dcc273..ce7205c07 100644 --- a/ui/src/main/java/io/xeres/ui/model/profile/ProfileMapper.java +++ b/ui/src/main/java/io/xeres/ui/model/profile/ProfileMapper.java @@ -42,6 +42,7 @@ public static Profile fromDTO(ProfileDTO dto) profile.setId(dto.id()); profile.setName(dto.name()); profile.setPgpIdentifier(Long.parseLong(dto.pgpIdentifier())); + profile.setCreated(dto.created()); profile.setProfileFingerprint(new ProfileFingerprint(dto.pgpFingerprint())); profile.setPgpPublicKeyData(dto.pgpPublicKeyData()); profile.setAccepted(dto.accepted()); diff --git a/ui/src/main/resources/view/chat/chatview.fxml b/ui/src/main/resources/view/chat/chatview.fxml index 86161ab51..4632c4a40 100644 --- a/ui/src/main/resources/view/chat/chatview.fxml +++ b/ui/src/main/resources/view/chat/chatview.fxml @@ -88,7 +88,7 @@ - + diff --git a/ui/src/main/resources/view/contact/contactview.fxml b/ui/src/main/resources/view/contact/contactview.fxml index 7f24eb4c1..942c400fe 100644 --- a/ui/src/main/resources/view/contact/contactview.fxml +++ b/ui/src/main/resources/view/contact/contactview.fxml @@ -17,9 +17,8 @@ ~ along with Xeres. If not, see . --> - - + @@ -40,9 +39,12 @@ + + + - + @@ -55,57 +57,53 @@ - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/resources/view/windows.css b/ui/src/main/resources/view/windows.css index 628af0809..f3743145c 100644 --- a/ui/src/main/resources/view/windows.css +++ b/ui/src/main/resources/view/windows.css @@ -24,4 +24,5 @@ /* this is needed to display emojis before sending them */ .chat-send { -fx-font-family: "Segoe UI Emoji"; + -fx-pref-height: 36px; /* and this is needed otherwise the widget is too small */ } \ No newline at end of file