github.com/benoitkugler/goacve@v0.0.0-20201217100549-151ce6e55dc8/server/documents/documents.go (about) 1 // Fournit un service global de gestion de fichiers. 2 package documents 3 4 import ( 5 "bytes" 6 "database/sql" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "io/ioutil" 11 "mime" 12 "mime/multipart" 13 "net/url" 14 "path" 15 "strconv" 16 "strings" 17 "time" 18 19 "github.com/benoitkugler/goACVE/logs" 20 "github.com/benoitkugler/goACVE/server/core/apiserver" 21 dm "github.com/benoitkugler/goACVE/server/core/datamodel" 22 cd "github.com/benoitkugler/goACVE/server/core/documents" 23 rd "github.com/benoitkugler/goACVE/server/core/rawdata" 24 "github.com/benoitkugler/goACVE/server/shared" 25 "github.com/labstack/echo" 26 ) 27 28 const ( 29 EndPointDocument = "/document" 30 EndPointMiniature = "/miniature" 31 32 DocumentAbsolu = "abs" // document présent dans la base de données 33 DocumentRelatif = "rel" // document généré dynamiquement 34 // DocumentStatic = "static" // document statique 35 36 pathMiniatureError = "server/static/images/default_miniature.png" 37 ) 38 39 const ( 40 ListeVetements categorieDocument = iota 41 ListeParticipants 42 ) 43 44 type Controller struct { 45 shared.Controller 46 } 47 48 type PublicDocument struct { 49 IdCrypted string `json:"id_crypted,omitempty"` 50 NomClient rd.String `json:"nom_client,omitempty"` 51 Taille rd.Taille `json:"taille,omitempty"` 52 DateHeureModif rd.Time `json:"date_heure_modif,omitempty"` 53 UrlMiniature string `json:"url_miniature,omitempty"` 54 UrlDownload string `json:"url_download,omitempty"` 55 } 56 57 // PublicContrainte expose de façon sécurisée une contrainte sur le 58 // frontend 59 type PublicContrainte struct { 60 IdCrypted string `json:"id_crypted"` 61 Document PublicDocument `json:"document"` // optionnel 62 Nom rd.String `json:"nom"` 63 Description rd.String `json:"description"` 64 MaxDocs int `json:"max_docs"` 65 JoursValide int `json:"jours_valide"` 66 } 67 68 // ContrainteDocuments associe une contrainte de document 69 // aux documents la remplissant 70 type ContrainteDocuments struct { 71 Contrainte PublicContrainte `json:"contrainte"` 72 Docs []PublicDocument `json:"docs"` 73 } 74 75 type categorieDocument int 76 77 // MetaDoc permet de retrouver le document à télécharger/miniaturiser 78 // à partir d'un lien crypté 79 type MetaDoc struct { 80 IdCamp int64 81 Categorie categorieDocument 82 } 83 84 // Share renvoie créé les liens de partage 85 func (m MetaDoc) Share(p logs.Encrypteur, host string) (doc PublicDocument, err error) { 86 idCrypted, err := shared.Encode(p, m) 87 if err != nil { 88 return doc, err 89 } 90 out := PublicDocument{ 91 IdCrypted: idCrypted, 92 UrlDownload: shared.BuildUrl(host, EndPointDocument, map[string]string{ 93 "crypted-id": idCrypted, 94 "mode": DocumentRelatif, 95 }), 96 UrlMiniature: shared.BuildUrl(host, EndPointMiniature, map[string]string{ 97 "crypted-id": idCrypted, 98 "mode": DocumentRelatif, 99 }), 100 } 101 switch m.Categorie { 102 case ListeVetements: 103 out.NomClient = "Liste de vêtements" 104 case ListeParticipants: 105 out.NomClient = "Liste des participants" 106 } 107 return out, nil 108 } 109 110 // loadDataForDocuments renvoie un accès contenant une base locale 111 // initalisée avec les données nécessaires à la génération dynamique des documents 112 // une erreur est renvoyé si le camp est simple 113 // Attention : la base locale est donc PARTIELLE. 114 func (ct Controller) loadDataForDocuments(idCamp int64) (dm.AccesCamp, error) { 115 camp, err := rd.SelectCamp(ct.DB, idCamp) 116 if err != nil { 117 return dm.AccesCamp{}, err 118 } 119 if camp.InscriptionSimple { 120 return dm.AccesCamp{}, fmt.Errorf("Le camp %s ne propose pas de documents (camp simplifié).", camp.Label()) 121 } 122 123 groupes, err := rd.SelectGroupesByIdCamps(ct.DB, idCamp) 124 if err != nil { 125 return dm.AccesCamp{}, err 126 } 127 128 parts, err := rd.SelectParticipantsByIdCamps(ct.DB, idCamp) 129 if err != nil { 130 return dm.AccesCamp{}, err 131 } 132 133 rows, err := ct.DB.Query(`SELECT factures.* FROM factures 134 JOIN participants ON factures.id = participants.id_facture 135 WHERE participants.id = ANY($1)`, parts.Ids().AsSQL()) 136 if err != nil { 137 return dm.AccesCamp{}, err 138 } 139 facs, err := rd.ScanFactures(rows) 140 if err != nil { 141 return dm.AccesCamp{}, err 142 } 143 144 idsPersonnes := make(rd.Ids, 0, len(parts)+len(facs)) // non unique, mais ce n'est pas très grave 145 for _, part := range parts { 146 idsPersonnes = append(idsPersonnes, part.IdPersonne) 147 } 148 for _, fac := range facs { 149 idsPersonnes = append(idsPersonnes, fac.IdPersonne) 150 } 151 152 rows, err = ct.DB.Query("SELECT * FROM personnes WHERE id = ANY($1)", idsPersonnes.AsSQL()) 153 if err != nil { 154 return dm.AccesCamp{}, err 155 } 156 pers, err := rd.ScanPersonnes(rows) 157 if err != nil { 158 return dm.AccesCamp{}, err 159 } 160 base := dm.BaseLocale{Personnes: pers, Camps: rd.Camps{camp.Id: camp}, Factures: facs, Groupes: groupes, Participants: parts} 161 return base.NewCamp(idCamp), nil 162 } 163 164 func (ct Controller) loadDocument(cryptedId string, isMin bool) ([]byte, string, error) { 165 refOrigine := shared.OrDocument 166 if isMin { 167 refOrigine = shared.OrMiniature 168 } 169 idDocument, err := shared.DecodeID(ct.Signing, cryptedId, refOrigine) 170 if err != nil { 171 return nil, "", err 172 } 173 174 doc, err := rd.SelectDocument(ct.DB, idDocument) 175 if err != nil { 176 return nil, "", err 177 } 178 179 // optimisation: on ne charge pas le contenu systématiquement 180 fields := "id_document, contenu, null" 181 if isMin { 182 fields = "id_document, null, miniature" 183 } 184 185 row := ct.DB.QueryRow(fmt.Sprintf("SELECT %s FROM contenu_documents WHERE id_document = $1", fields), idDocument) 186 contenu, err := rd.ScanContenuDocument(row) 187 if err != nil { 188 return nil, "", err 189 } 190 out := contenu.Contenu 191 if isMin { 192 out = contenu.Miniature 193 } 194 return out, doc.NomClient.String(), nil 195 } 196 197 func (ct Controller) genereDocument(cryptedId string, isMin bool) ([]byte, string, error) { 198 var cr MetaDoc 199 if err := shared.Decode(ct.Signing, cryptedId, &cr); err != nil { 200 return nil, "", err 201 } 202 camp, err := ct.loadDataForDocuments(cr.IdCamp) 203 if err != nil { 204 return nil, "", err 205 } 206 var renderer cd.DynamicDocument 207 208 switch cr.Categorie { 209 case ListeParticipants: 210 renderer = cd.NewListeParticipants(camp) 211 case ListeVetements: 212 renderer = cd.NewListeVetements(camp.RawData()) 213 default: 214 return nil, "", errors.New("invalid document categorie") 215 } 216 pdfBytes, err := renderer.Generate() 217 if err != nil { 218 return nil, "", err 219 } 220 if isMin { 221 pdfBytes, err = ComputeMiniature2(pdfBytes) 222 if err != nil { 223 return nil, "", err 224 } 225 } 226 return pdfBytes, renderer.FileName(), nil 227 } 228 229 func Attachment(c echo.Context, filename string, doc []byte, asAttachment bool) error { 230 mimeType := mime.TypeByExtension(path.Ext(filename)) 231 if asAttachment { 232 u := url.URL{Path: filename} 233 filename = u.String() 234 c.Response().Header().Set("Content-Disposition", "attachment; filename="+filename) 235 c.Response().Header().Set("Content-Length", strconv.Itoa(len(doc))) 236 } 237 return c.Blob(200, mimeType, doc) 238 } 239 240 func PublieDocument(p logs.Encrypteur, host string, doc rd.Document) (PublicDocument, error) { 241 u := PublicDocument{ 242 DateHeureModif: doc.DateHeureModif, 243 NomClient: doc.NomClient, 244 Taille: doc.Taille, 245 } 246 idCrypted, err := shared.EncodeID(p, shared.OrDocument, doc.Id) 247 if err != nil { 248 return u, shared.FormatErr("Une erreur interne empêche le cryptage des informations.", err) 249 } 250 idCryptedMiniature, err := shared.EncodeID(p, shared.OrMiniature, doc.Id) 251 if err != nil { 252 return u, shared.FormatErr("Une erreur interne empêche le cryptage des informations.", err) 253 } 254 u.IdCrypted = idCrypted 255 u.UrlDownload = shared.BuildUrl(host, EndPointDocument, map[string]string{ 256 "crypted-id": idCrypted, 257 "mode": DocumentAbsolu, 258 }) 259 u.UrlMiniature = shared.BuildUrl(host, EndPointMiniature, map[string]string{ 260 "crypted-id": idCryptedMiniature, 261 "mode": DocumentAbsolu, 262 }) 263 return u, nil 264 } 265 266 // PublieContrainte charge un éventuel document à remplir, et le publie, 267 // et crypte les ids. 268 func PublieContrainte(p logs.Encrypteur, db rd.DB, host string, contrainte rd.Contrainte) (out PublicContrainte, err error) { 269 out = PublicContrainte{ 270 Nom: contrainte.Nom, 271 Description: contrainte.Description, 272 MaxDocs: contrainte.MaxDocs, 273 JoursValide: contrainte.JoursValide, 274 } 275 if contrainte.IdDocument.IsNotNil() { 276 aRemplir, err := rd.SelectDocument(db, contrainte.IdDocument.Int64) 277 if err != nil { 278 return out, err 279 } 280 out.Document, err = PublieDocument(p, host, aRemplir) 281 if err != nil { 282 return out, err 283 } 284 } 285 out.IdCrypted, err = shared.EncodeID(p, shared.OrContrainte, contrainte.Id) 286 return out, err 287 } 288 289 func ReceiveUpload(fileHeader *multipart.FileHeader, maxSize int64) (filename string, content []byte, err error) { 290 if fileHeader.Size > maxSize { 291 return "", nil, fmt.Errorf("Document trop lourd ! (%d MB)", fileHeader.Size/1000000) 292 } 293 294 f, err := fileHeader.Open() 295 if err != nil { 296 return "", nil, fmt.Errorf("Impossible de lire le fichier: %s", err) 297 } 298 defer f.Close() 299 300 content, err = ioutil.ReadAll(f) 301 if err != nil { 302 return "", nil, fmt.Errorf("Impossible de lire le fichier: %s", err) 303 } 304 if len(content) == 0 { 305 return "", nil, errors.New("Le fichier transmis est vide !") 306 } 307 if int64(len(content)) != fileHeader.Size { 308 return "", nil, fmt.Errorf("Contenu invalide (taille %d, attendue %d)", len(content), fileHeader.Size) 309 } 310 return fileHeader.Filename, content, nil 311 } 312 313 // ReceiveUploadWithMeta lie les meta données contenues dans le champ 'meta' 314 // du formulaire, puis appelle `ReceiveUpload` 315 func ReceiveUploadWithMeta(c echo.Context, maxSize int64, meta interface{}) (filename string, content []byte, err error) { 316 jsonMeta := c.FormValue("meta") 317 err = json.Unmarshal([]byte(jsonMeta), meta) 318 if err != nil { 319 return "", nil, fmt.Errorf("Meta données invalides : %s", err) 320 } 321 fileHeader, err := c.FormFile("file") 322 if err != nil { 323 return "", nil, fmt.Errorf("Paramètres d'upload invalides : %s", err) 324 } 325 return ReceiveUpload(fileHeader, maxSize) 326 } 327 328 // uploadDocument lit la requête et appelle `saveDocument` 329 func (ct Controller) uploadDocument(fileHeader *multipart.FileHeader, idDocument int64) (out rd.Document, err error) { 330 filename, content, err := ReceiveUpload(fileHeader, apiserver.DOCUMENT_MAX_SIZE) 331 if err != nil { 332 return out, err 333 } 334 335 tx, err := ct.DB.Begin() 336 if err != nil { 337 return out, shared.FormatErr("Base de données injoinable.", err) 338 } 339 doc, err := SaveDocument(tx, idDocument, content, filename, true) 340 if err != nil { 341 return doc, shared.Rollback(tx, err) 342 } 343 err = tx.Commit() 344 return doc, err 345 } 346 347 // SaveDocument créé la miniature et met à jour la base de données 348 // Renvoi le document mis à jour. 349 // Ne commit ou ne rollback PAS. 350 func SaveDocument(tx *sql.Tx, idDocument int64, fileContent []byte, filename string, allowCompress bool) (rd.Document, error) { 351 doc, err := rd.SelectDocument(tx, idDocument) 352 if err != nil { 353 return doc, err 354 } 355 356 min := new(bytes.Buffer) 357 if err := ComputeMiniature(filename, bytes.NewReader(fileContent), min); err != nil { 358 return doc, fmt.Errorf("échec de la création de la miniature (taille du document d'origine :%d) : %s", 359 len(fileContent), err) 360 } 361 if min.Len() == 0 { 362 return doc, errors.New("échec de le création de la miniature : contenu vide") 363 } 364 365 if allowCompress { 366 filename, fileContent, err = compressDocument(filename, fileContent) 367 if err != nil { 368 return doc, shared.FormatErr("Erreur pendant la compression du document.", err) 369 } 370 } 371 372 err = checkDoublons(tx, fileContent, idDocument) 373 if err != nil { 374 return doc, err 375 } 376 377 doc.NomClient = rd.String(filename) 378 doc.DateHeureModif = rd.Time(time.Now()) 379 doc.Taille = rd.Taille(len(fileContent)) // taille compressée 380 381 // on supprime l'éventuel contenu ... 382 _, err = rd.DeleteContenuDocumentsByIdDocuments(tx, doc.Id) 383 if err != nil { 384 return doc, err 385 } 386 // ... et on le met à jour 387 contenuDoc := rd.ContenuDocument{IdDocument: doc.Id, Contenu: fileContent, Miniature: min.Bytes()} 388 err = rd.InsertManyContenuDocuments(tx, contenuDoc) 389 if err != nil { 390 return doc, err 391 } 392 return doc.Update(tx) 393 } 394 395 // LoadDocsAndAdd charge les documents et les ajoute à l'archive, sans la fermer. 396 // `prefixes` peut contenir un label à ajouter au nom du document 397 func LoadDocsAndAdd(tx rd.DB, idsDocs rd.Ids, archive *cd.ArchiveZip, prefixes map[int64]string) error { 398 rows, err := tx.Query("SELECT * FROM documents WHERE id = ANY($1)", idsDocs.AsSQL()) 399 if err != nil { 400 return err 401 } 402 documents, err := rd.ScanDocuments(rows) 403 if err != nil { 404 return err 405 } 406 407 // contenu 408 contenus, err := rd.SelectContenuDocumentsByIdDocuments(tx, idsDocs...) 409 if err != nil { 410 return err 411 } 412 413 for _, contenu := range contenus { 414 if len(contenu.Contenu) == 0 { 415 continue // un document vide (erreur dans l'upload) est ignoré 416 } 417 r := bytes.NewReader(contenu.Contenu) 418 filename := documents[contenu.IdDocument].NomClient.String() 419 if prefix := prefixes[contenu.IdDocument]; prefix != "" { 420 filename = prefix + " " + filename 421 } 422 filename = strings.Replace(filename, "/", "-", -1) // / indique un dossier 423 archive.AddFile(filename, r) 424 } 425 return nil 426 } 427 428 // ne commit pas ne rollback pas 429 func insertDocument(tx *sql.Tx, idPersonne, idContrainte int64, description rd.String) (rd.Document, error) { 430 // on créé les metas données 431 document := rd.Document{ 432 DateHeureModif: rd.Time(time.Now()), 433 Description: description, 434 } 435 document, err := document.Insert(tx) 436 if err != nil { 437 return rd.Document{}, err 438 } 439 440 // puis on créé le lien avec la personne et la contrainte 441 docPers := rd.DocumentPersonne{ 442 IdDocument: document.Id, 443 IdContrainte: idContrainte, 444 IdPersonne: idPersonne, 445 } 446 err = rd.InsertManyDocumentPersonnes(tx, docPers) 447 return document, err 448 } 449 450 type ParamsNewDocument struct { 451 IdContrainte int64 452 453 Description rd.String 454 455 FileContent []byte 456 FileName string 457 } 458 459 // CreeDocumentPersonne les metas donnés d'un document répondant à la contrainte donnée 460 // pour la personne donnée, et upload le contenu 461 func CreeDocumentPersonne(p logs.Encrypteur, db *sql.DB, host string, idPersonne int64, params ParamsNewDocument) (PublicDocument, error) { 462 tx, err := db.Begin() 463 if err != nil { 464 return PublicDocument{}, err 465 } 466 467 // on vérifie la 'taille' de la contrainte 468 contrainte, err := rd.SelectContrainte(tx, params.IdContrainte) 469 if err != nil { 470 return PublicDocument{}, shared.Rollback(tx, err) 471 } 472 row := tx.QueryRow("SELECT count(*) FROM document_personnes WHERE id_personne = $1 AND id_contrainte = $2", idPersonne, params.IdContrainte) 473 var nbDocs int 474 if err = row.Scan(&nbDocs); err != nil { 475 return PublicDocument{}, shared.Rollback(tx, err) 476 } 477 if nbDocs >= contrainte.MaxDocs { 478 _ = tx.Rollback() 479 return PublicDocument{}, fmt.Errorf("Le nombre maximal de documents (%d) pour la catégorie %s est atteint.", 480 contrainte.MaxDocs, contrainte.Nom) 481 } 482 483 // meta données 484 document, err := insertDocument(tx, idPersonne, params.IdContrainte, params.Description) 485 if err != nil { 486 return PublicDocument{}, shared.Rollback(tx, err) 487 } 488 489 // contenu 490 document, err = SaveDocument(tx, document.Id, params.FileContent, params.FileName, true) 491 if err != nil { 492 return PublicDocument{}, shared.Rollback(tx, err) 493 } 494 495 out, err := PublieDocument(p, host, document) 496 if err != nil { 497 return PublicDocument{}, shared.Rollback(tx, err) 498 } 499 return out, tx.Commit() 500 } 501 502 // CreeDocumentEquipier les metas donnés d'un document répondant à la contrainte donnée 503 // pour l'équipier donné, et upload le contenu 504 func CreeDocumentEquipier(p logs.Encrypteur, db *sql.DB, host string, idEquipier int64, params ParamsNewDocument) (PublicDocument, error) { 505 equipier, err := rd.SelectEquipier(db, idEquipier) 506 if err != nil { 507 return PublicDocument{}, err 508 } 509 510 return CreeDocumentPersonne(p, db, host, equipier.IdPersonne, params) 511 }