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  }