Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ SecurityFilterChain securite(HttpSecurity http, ConnexionGithub connexionGithub,
// Lectures publiques, sans connexion (le jury, un visiteur) : uniquement ces routes et seulement
// en GET. Elles ne montrent que des chiffres agrégés et ce que les projets montrent déjà à tous
// (contracts/systalink.yaml et contracts/vitrine.yaml, `security: []`). Toute autre route reste fermée.
.requestMatchers(HttpMethod.GET, "/api/systalink/tableau-de-bord", "/api/systalink/tendances", "/api/vitrine")
// /api/profils/* : un profil publié, pour le lien partagé (#159). La recherche (/api/profils) reste réservée.
.requestMatchers(HttpMethod.GET, "/api/systalink/tableau-de-bord", "/api/systalink/tendances", "/api/vitrine",
"/api/profils/*")
.permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth -> oauth
Expand Down
177 changes: 172 additions & 5 deletions apps/api/src/main/java/dev/jamii/api/profils/ModificationProfil.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,21 @@ final class ModificationProfil {
static final Set<String> DISPONIBILITES = Set.of("disponible", "occupe", "indisponible");

private static final Set<String> CHAMPS = Set.of("bio", "pays", "ville", "disponibilite", "heuresParSemaine",
"competencesDeclarees", "competencesIaRetenues", "afficherResumeIa", "version");
"competencesDeclarees", "competencesIaRetenues", "afficherResumeIa", "version", "titre", "linkedin", "siteWeb",
"langues", "competencesPhares", "realisations");

static final int LANGUES_MAX = 8;

static final int PHARES_MAX = 3;

static final int REALISATIONS_MAX = 6;

static final int TECHNOLOGIES_MAX = 8;

private static final Set<String> CHAMPS_REALISATION = Set.of("titre", "role", "description", "technologies", "url",
"annee");

private static final Set<String> CODES_LANGUES = Set.of(Locale.getISOLanguages());

private static final Set<String> CODES_PAYS = Set.of(Locale.getISOCountries());

Expand Down Expand Up @@ -48,6 +62,38 @@ else if (version != profil.version()) {
}
});

texte(patch, "titre", 80, erreurs).ifPresent(v -> changements.add(() -> profil.titre(v.valeur())));
texte(patch, "linkedin", 300, erreurs).ifPresent(v -> {
if (v.valeur() != null && !lienValide(v.valeur(), true)) {
erreurs.add(champ("linkedin", "format_invalide"));
}
else {
changements.add(() -> profil.linkedin(v.valeur()));
}
});
texte(patch, "siteWeb", 300, erreurs).ifPresent(v -> {
if (v.valeur() != null && !lienValide(v.valeur(), false)) {
erreurs.add(champ("siteWeb", "format_invalide"));
}
else {
changements.add(() -> profil.siteWeb(v.valeur()));
}
});

if (patch.containsKey("langues")) {
langues(patch.get("langues"), erreurs).ifPresent(l -> changements.add(() -> profil.langues(l)));
}

if (patch.containsKey("competencesPhares")) {
listeDeCompetences(patch.get("competencesPhares"), "competencesPhares", PHARES_MAX, erreurs)
.ifPresent(c -> changements.add(() -> profil.competencesPhares(List.copyOf(c))));
}

if (patch.containsKey("realisations")) {
realisations(patch.get("realisations"), erreurs)
.ifPresent(r -> changements.add(() -> profil.remplacerRealisations(r)));
}

if (patch.containsKey("disponibilite")) {
if (patch.get("disponibilite") instanceof String d && DISPONIBILITES.contains(d)) {
changements.add(() -> profil.disponibilite(d));
Expand Down Expand Up @@ -121,20 +167,25 @@ private static java.util.Optional<Valeur> texte(Map<String, Object> patch, Strin
}

private static java.util.Optional<Set<String>> competences(Object brut, List<ErreurProfil.Champ> erreurs) {
return listeDeCompetences(brut, "competencesDeclarees", 20, erreurs);
}

private static java.util.Optional<Set<String>> listeDeCompetences(Object brut, String nom, int max,
List<ErreurProfil.Champ> erreurs) {
if (!(brut instanceof List<?> liste)) {
erreurs.add(champ("competencesDeclarees", "format_invalide"));
erreurs.add(champ(nom, "format_invalide"));
return java.util.Optional.empty();
}
if (liste.size() > 20) {
erreurs.add(champ("competencesDeclarees", "trop_d_elements"));
if (liste.size() > max) {
erreurs.add(champ(nom, "trop_d_elements"));
return java.util.Optional.empty();
}
Set<String> propres = new LinkedHashSet<>();
int avant = erreurs.size();
for (int i = 0; i < liste.size(); i++) {
var normalisee = liste.get(i) instanceof String s ? Competences.normaliser(s) : java.util.Optional.<String>empty();
if (normalisee.isEmpty()) {
erreurs.add(champ("competencesDeclarees[" + i + "]", "format_invalide"));
erreurs.add(champ(nom + "[" + i + "]", "format_invalide"));
}
else {
propres.add(normalisee.get());
Expand Down Expand Up @@ -168,6 +219,122 @@ private static java.util.Optional<Set<String>> retenues(Object brut, Profil prof
return erreurs.size() == avant ? java.util.Optional.of(gardees) : java.util.Optional.empty();
}

/**
* Un lien affichable sans risque : http(s) seulement (jamais javascript: ni data:), avec un vrai nom d'hôte.
* `linkedin` : l'hôte doit être linkedin.com ou l'un de ses sous-domaines.
*/
static boolean lienValide(String lien, boolean linkedin) {
try {
java.net.URI uri = new java.net.URI(lien);
String schema = uri.getScheme();
String hote = uri.getHost();
if (schema == null || hote == null || uri.getUserInfo() != null || !hote.contains(".")) {
return false;
}
schema = schema.toLowerCase(Locale.ROOT);
hote = hote.toLowerCase(Locale.ROOT);
if (linkedin) {
return schema.equals("https") && (hote.equals("linkedin.com") || hote.endsWith(".linkedin.com"));
}
return schema.equals("https") || schema.equals("http");
}
catch (java.net.URISyntaxException e) {
return false;
}
}

private static java.util.Optional<List<String>> langues(Object brut, List<ErreurProfil.Champ> erreurs) {
if (!(brut instanceof List<?> liste)) {
erreurs.add(champ("langues", "format_invalide"));
return java.util.Optional.empty();
}
if (liste.size() > LANGUES_MAX) {
erreurs.add(champ("langues", "trop_d_elements"));
return java.util.Optional.empty();
}
Set<String> propres = new LinkedHashSet<>();
int avant = erreurs.size();
for (int i = 0; i < liste.size(); i++) {
if (liste.get(i) instanceof String code && CODES_LANGUES.contains(code)) {
propres.add(code);
}
else {
erreurs.add(champ("langues[" + i + "]", "format_invalide"));
}
}
return erreurs.size() == avant ? java.util.Optional.of(List.copyOf(propres)) : java.util.Optional.empty();
}

/** Remplace la liste entière. Chaque réalisation est validée champ par champ, comme le profil. */
private static java.util.Optional<List<Profil.Realisation>> realisations(Object brut,
List<ErreurProfil.Champ> erreurs) {
if (!(brut instanceof List<?> liste)) {
erreurs.add(champ("realisations", "format_invalide"));
return java.util.Optional.empty();
}
if (liste.size() > REALISATIONS_MAX) {
erreurs.add(champ("realisations", "trop_d_elements"));
return java.util.Optional.empty();
}
int avant = erreurs.size();
List<Profil.Realisation> propres = new ArrayList<>();
for (int i = 0; i < liste.size(); i++) {
String prefixe = "realisations[" + i + "].";
if (!(liste.get(i) instanceof Map<?, ?> brute)) {
erreurs.add(champ("realisations[" + i + "]", "format_invalide"));
continue;
}
@SuppressWarnings("unchecked")
Map<String, Object> r = (Map<String, Object>) brute;
r.keySet().stream().filter(c -> !CHAMPS_REALISATION.contains(c))
.forEach(c -> erreurs.add(champ(prefixe + c, "inconnu")));
String titre = texteDe(r, "titre", 80, prefixe, erreurs);
if (titre == null || titre.length() < 2) {
erreurs.add(champ(prefixe + "titre", "obligatoire"));
}
String role = texteDe(r, "role", 60, prefixe, erreurs);
String description = texteDe(r, "description", 300, prefixe, erreurs);
String url = texteDe(r, "url", 300, prefixe, erreurs);
if (url != null && !lienValide(url, false)) {
erreurs.add(champ(prefixe + "url", "format_invalide"));
}
Integer annee = null;
Object a = r.get("annee");
if (a instanceof Integer n && n >= 1990 && n <= java.time.Year.now().getValue() + 1) {
annee = n;
}
else if (a != null) {
erreurs.add(champ(prefixe + "annee", "hors_limites"));
}
String technologies = null;
if (r.get("technologies") != null) {
var techs = listeDeCompetences(r.get("technologies"), prefixe + "technologies", TECHNOLOGIES_MAX, erreurs);
technologies = techs.filter(t -> !t.isEmpty()).map(t -> String.join(",", t)).orElse(null);
}
propres.add(new Profil.Realisation(titre, role, description, technologies, url, annee));
}
return erreurs.size() == avant ? java.util.Optional.of(propres) : java.util.Optional.empty();
}

/** Un texte facultatif d'un objet imbriqué : vide ou null donne null, les espaces autour sont retirés. */
private static String texteDe(Map<String, Object> objet, String nom, int max, String prefixe,
List<ErreurProfil.Champ> erreurs) {
Object brut = objet.get(nom);
if (brut == null) {
return null;
}
if (!(brut instanceof String s)) {
erreurs.add(champ(prefixe + nom, "format_invalide"));
return null;
}
String propre = s.strip();
if (propre.length() > max) {
erreurs.add(champ(prefixe + nom, "trop_long"));
return null;
}
return propre.isEmpty() ? null : propre;
}

private static ErreurProfil.Champ champ(String nom, String code) {
return new ErreurProfil.Champ(nom, code);
}
Expand Down
76 changes: 76 additions & 0 deletions apps/api/src/main/java/dev/jamii/api/profils/Profil.java
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ class Profil {
/** La dernière carte proposée par l'IA, en JSON (CartesIa.Stockage). Privée tant que la personne n'a rien gardé. */
private String competencesProposees;

private String titre;

private String linkedin;

private String siteWeb;

/** Codes ISO 639-1 séparés par des virgules (voir V20). */
private String langues;

/** Compétences mises en avant, séparées par des virgules. */
private String competencesPhares;

@Version
private int version;

Expand All @@ -76,6 +88,11 @@ class Profil {
@OrderColumn(name = "rang")
private List<DepotGithub> depots = new ArrayList<>();

@ElementCollection
@CollectionTable(name = "profil_realisation", joinColumns = @JoinColumn(name = "utilisateur_id"))
@OrderColumn(name = "rang")
private List<Realisation> realisations = new ArrayList<>();

protected Profil() {
}

Expand Down Expand Up @@ -107,6 +124,16 @@ record DepotGithub(String nom, String url, String description, String langage, i
@Column(name = "mis_a_jour_le") Instant misAJourLe) {
}

/** Une réalisation déclarée hors de Jamii. `technologies` : compétences séparées par des virgules. */
@Embeddable
record Realisation(String titre, String role, String description, String technologies, String url, Integer annee) {

List<String> listeTechnologies() {
return technologies == null ? List.of() : List.of(technologies.split(","));
}

}

boolean generationEnCours(Instant maintenant) {
return "en_cours".equals(generationStatut) && generationLanceeLe != null
&& generationLanceeLe.plus(GENERATION_PERDUE).isAfter(maintenant);
Expand Down Expand Up @@ -194,6 +221,55 @@ void heuresParSemaine(Integer heures) {
this.heuresParSemaine = heures;
}

void titre(String titre) {
this.titre = titre;
}

void linkedin(String linkedin) {
this.linkedin = linkedin;
}

void siteWeb(String siteWeb) {
this.siteWeb = siteWeb;
}

void langues(List<String> langues) {
this.langues = langues.isEmpty() ? null : String.join(",", langues);
}

void competencesPhares(List<String> phares) {
this.competencesPhares = phares.isEmpty() ? null : String.join(",", phares);
}

void remplacerRealisations(List<Realisation> nouvelles) {
realisations.clear();
realisations.addAll(nouvelles);
}

String titre() {
return titre;
}

String linkedin() {
return linkedin;
}

String siteWeb() {
return siteWeb;
}

List<String> langues() {
return langues == null ? List.of() : List.of(langues.split(","));
}

List<String> competencesPhares() {
return competencesPhares == null ? List.of() : List.of(competencesPhares.split(","));
}

List<Realisation> realisations() {
return List.copyOf(realisations);
}

void afficherResumeIa(boolean afficher) {
this.afficherResumeIa = afficher;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ class ProfilsController {
this.comptes = comptes;
}

/** Un profil non publié répond 404 aux autres : on ne révèle pas qu'il existe (api-conventions.md §5). */
/**
* Un profil non publié répond 404 aux autres : on ne révèle pas qu'il existe (api-conventions.md §5). Un profil
* publié se lit sans compte (#159) : c'est le lien partagé et le QR code de la carte. Jamais l'e-mail.
*/
@GetMapping("/api/profils/{utilisateurId}")
@Transactional(readOnly = true)
Vues.ProfilPublic lire(@AuthenticationPrincipal UtilisateurConnecte moi, @PathVariable UUID utilisateurId) {
Profil profil = profils.findById(utilisateurId)
.filter(p -> p.publie() || p.utilisateurId().equals(moi.id()))
.filter(p -> p.publie() || (moi != null && p.utilisateurId().equals(moi.id())))
.orElseThrow(ErreurProfil::introuvable);
Compte compte = comptes.lire(utilisateurId).orElseThrow(ErreurProfil::introuvable);
return Vues.ProfilPublic.depuis(profil, compte);
Expand Down
25 changes: 19 additions & 6 deletions apps/api/src/main/java/dev/jamii/api/profils/Vues.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,29 @@ record CompetenceProfil(String competence, String source, String niveau, List<St
record DepotGithub(String nom, String url, String description, String langage, int etoiles, Instant misAJourLe) {
}

/** Une réalisation déclarée hors de Jamii : le front l'étiquette « Déclaré », jamais « Vérifié ». */
record Realisation(String titre, String role, String description, List<String> technologies, String url,
Integer annee) {
}

/** Schéma Profil : ce que les autres voient d'un profil publié. Jamais l'e-mail. */
record ProfilPublic(UUID utilisateurId, String nom, String avatarUrl, String loginGithub, String bio, String resumeIa,
String pays, String ville, String disponibilite, Integer heuresParSemaine, List<Langage> langages,
List<CompetenceProfil> competences, List<Object> projets, List<DepotGithub> depotsGithub,
List<Object> fiabilite, Instant misAJourLe) {
record ProfilPublic(UUID utilisateurId, String nom, String avatarUrl, String loginGithub, String titre, String bio,
String resumeIa, String pays, String ville, String disponibilite, Integer heuresParSemaine,
String linkedin, String siteWeb, List<String> langues, List<String> competencesPhares,
List<Realisation> realisations, List<Langage> langages, List<CompetenceProfil> competences,
List<Object> projets, List<DepotGithub> depotsGithub, List<Object> fiabilite, Instant misAJourLe) {

static ProfilPublic depuis(dev.jamii.api.profils.Profil p, Compte compte) {
return new ProfilPublic(p.utilisateurId(), compte.nom(), compte.avatarUrl(), compte.loginGithub(), p.bio(),
return new ProfilPublic(p.utilisateurId(), compte.nom(), compte.avatarUrl(), compte.loginGithub(), p.titre(),
p.bio(),
p.afficherResumeIa() ? p.resumeIa() : null, p.pays(), p.ville(), p.disponibilite(),
p.heuresParSemaine(), p.langages().stream().map(l -> new Langage(l.nom(), l.part())).toList(),
p.heuresParSemaine(), p.linkedin(), p.siteWeb(), p.langues(), p.competencesPhares(),
p.realisations()
.stream()
.map(r -> new Realisation(r.titre(), r.role(), r.description(), r.listeTechnologies(), r.url(),
r.annee()))
.toList(),
p.langages().stream().map(l -> new Langage(l.nom(), l.part())).toList(),
competences(p), List.of(),
p.depots().stream()
.map(d -> new DepotGithub(d.nom(), d.url(), d.description(), d.langage(), d.etoiles(),
Expand Down
Loading
Loading