github.com/benoitkugler/goacve@v0.0.0-20201217100549-151ce6e55dc8/server/directeurs/lettre.go (about)

     1  package directeurs
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"log"
     7  	"mime"
     8  	"mime/multipart"
     9  	"net/url"
    10  	"path/filepath"
    11  	"strings"
    12  	"time"
    13  
    14  	rd "github.com/benoitkugler/goACVE/server/core/rawdata"
    15  	"github.com/benoitkugler/goACVE/server/core/utils/pdf"
    16  	"github.com/benoitkugler/goACVE/server/documents"
    17  	"github.com/benoitkugler/goACVE/server/shared"
    18  	"github.com/labstack/echo"
    19  	"github.com/lib/pq"
    20  	"golang.org/x/net/html"
    21  	"golang.org/x/net/html/atom"
    22  )
    23  
    24  const (
    25  	MAX_IMAGE_SIZE          = 1000000 // bytes
    26  	PATH_LOAD_IMAGES_LETTRE = "/api/imageslettre"
    27  )
    28  
    29  func GenerateNewImageLien(db rd.DB) (string, error) {
    30  	newLien := rd.RandString(64, false)
    31  	for {
    32  		row := db.QueryRow("SELECT count(*) FROM imageuploadeds WHERE lien = $1", newLien)
    33  		var count int
    34  		if err := row.Scan(&count); err != nil {
    35  			return "", err
    36  		}
    37  		if count == 0 { // OK, le lien est libre
    38  			break
    39  		}
    40  		newLien = rd.RandString(64, false) // déjà pris, on ré-essaye
    41  	}
    42  	return newLien, nil
    43  }
    44  
    45  func GetImagePublicURL(host, lien string) string {
    46  	return shared.BuildUrl(host, PATH_LOAD_IMAGES_LETTRE, map[string]string{"lien": lien})
    47  }
    48  
    49  func (ct Controller) uploadImageLettre(fileHeader *multipart.FileHeader, host string, idCamp int64) (string, error) {
    50  	filename, content, err := documents.ReceiveUpload(fileHeader, MAX_IMAGE_SIZE)
    51  	if err != nil {
    52  		return "", err
    53  	}
    54  
    55  	lien, err := GenerateNewImageLien(ct.DB)
    56  	if err != nil {
    57  		return "", err
    58  	}
    59  
    60  	tx, err := ct.DB.Begin()
    61  	if err != nil {
    62  		return "", err
    63  	}
    64  
    65  	img := rd.Imageuploaded{IdCamp: idCamp, Filename: filename, Content: content, Lien: lien}
    66  	err = rd.InsertManyImageuploadeds(tx, img)
    67  	if err != nil {
    68  		return "", shared.Rollback(tx, err)
    69  	}
    70  	err = tx.Commit()
    71  
    72  	location := GetImagePublicURL(host, lien)
    73  
    74  	return location, err
    75  }
    76  
    77  type urlAuth struct {
    78  	idCamp, token string
    79  }
    80  
    81  func (u urlAuth) BasicAuth() (username, password string, ok bool) {
    82  	return u.idCamp, u.token, true
    83  }
    84  
    85  // Location of the image as expected by Tinymce
    86  type UploadImageLettreOut struct {
    87  	Location string `json:"location"`
    88  }
    89  
    90  // UploadImageLettre enregistre une image de la lettre au directeur
    91  // et renvoie un chemin d'accès.
    92  func (ct Controller) UploadImageLettre(c echo.Context) error {
    93  	idCampS, token := c.Param("id_camp"), c.Param("token")
    94  	idCamp, _, err := ct.authentifie(urlAuth{idCamp: idCampS, token: token})
    95  	if err != nil {
    96  		return err
    97  	}
    98  	host := c.Request().Host
    99  	fileHeader, err := c.FormFile("file")
   100  	if err != nil {
   101  		return err
   102  	}
   103  	location, err := ct.uploadImageLettre(fileHeader, host, idCamp)
   104  	if err != nil {
   105  		return err
   106  	}
   107  	out := UploadImageLettreOut{Location: location}
   108  	return c.JSON(200, out)
   109  }
   110  
   111  // LoadImageLettre renvoie l'image demandée. La sécurité est assurée
   112  // par la complexité du lien
   113  func (ct Controller) LoadImageLettre(c echo.Context) error {
   114  	lien := c.QueryParam("lien")
   115  	// dans le cas ou plusieurs images ont le même lien (copie d'une lettre d'une année sur l'autre)
   116  	// une seule a besoin d'être renvoyée : on limite
   117  	row := ct.DB.QueryRow("SELECT * FROM imageuploadeds WHERE lien = $1 LIMIT 1", lien)
   118  	img, err := rd.ScanImageuploaded(row)
   119  	if err != nil {
   120  		return err
   121  	}
   122  	return c.Blob(200, mime.TypeByExtension(filepath.Ext(img.Filename)), img.Content)
   123  }
   124  
   125  // updateLettreDirecteur enregistre le html et génère et enregistre le document associé.
   126  // S'il existe, le document est remplacé.
   127  // La mise à jour de la lettre se fait en 6 étapes :
   128  //  0. mise à jour des paramètres (html + options)
   129  //	1. la lecture ou la création des méta-données du document final (Document)
   130  //	2. la génération du Pdf, via weasyprint
   131  //  3. l'enregistrement du Pdf comme contenu du document
   132  //	4. le partage du document final
   133  //	5. le nettoyage (en arrière plan) des images non utilisées
   134  // Dans le cas où la lettre n'a pas été modifié, la seule étape nécessaire est l'étape 4
   135  func (d DriverCampComplet) updateLettreDirecteur(host string, lettre rd.Lettredirecteur) (pub documents.PublicDocument, err error) {
   136  	var (
   137  		pdfContent []byte
   138  		dirPers    rd.Personne
   139  		fileName   string
   140  	)
   141  
   142  	tx, err := d.DB.Begin()
   143  	if err != nil {
   144  		return
   145  	}
   146  
   147  	// Etape 0: on prépare les paramètres de la lettre
   148  	lettre.IdCamp = d.camp.Id
   149  	lettre.Html = sanitizeHtml(lettre.Html) // sécurité + problèmes weasyprint
   150  
   151  	// Etape 2
   152  	docs, err := rd.SelectDocumentCampsByIdCamps(tx, d.camp.Id)
   153  	if err != nil {
   154  		err = shared.Rollback(tx, err)
   155  		return
   156  	}
   157  	lienDoc, hasFinalDocument := docs.FindLettre()
   158  	var docLettre rd.Document
   159  	if hasFinalDocument { // on récupère le document final déjà existant
   160  		docLettre, err = rd.SelectDocument(tx, lienDoc.IdDocument)
   161  		if err != nil {
   162  			err = shared.Rollback(tx, err)
   163  			return
   164  		}
   165  	} else { // pas encore de doc associé, on crée
   166  		docLettre = rd.Document{
   167  			Description:    "Généré à partir de la lettre du directeur.",
   168  			DateHeureModif: rd.Time(time.Now()),
   169  		}
   170  		docLettre, err = docLettre.Insert(tx)
   171  		if err != nil {
   172  			err = shared.Rollback(tx, err)
   173  			return
   174  		}
   175  		lienDoc = rd.DocumentCamp{IdCamp: d.Camp().Id, IdDocument: docLettre.Id, IsLettre: true}
   176  		err = rd.InsertManyDocumentCamps(tx, lienDoc)
   177  		if err != nil {
   178  			err = shared.Rollback(tx, err)
   179  			return
   180  		}
   181  	}
   182  
   183  	// Pour optimiser l'affichage, on commence par vérifier si le contenu a changé
   184  	// (et si il y a déjà un document final enregistré)
   185  	currentLettre, hasCurrentLettre, err := rd.SelectLettredirecteurByIdCamp(tx, d.camp.Id)
   186  	if err != nil {
   187  		err = shared.Rollback(tx, err)
   188  		return
   189  	}
   190  	if hasCurrentLettre && currentLettre == lettre && hasFinalDocument {
   191  		// la génération et l'enregistrement n'est pas nécessaire
   192  		goto Publie
   193  	}
   194  
   195  	// on efface un doublon potentiel
   196  	err = lettre.Delete(tx)
   197  	if err != nil {
   198  		err = shared.Rollback(tx, err)
   199  		return
   200  	}
   201  	// et on insère
   202  	err = rd.InsertManyLettredirecteurs(tx, lettre)
   203  	if err != nil {
   204  		err = shared.Rollback(tx, err)
   205  		return
   206  	}
   207  
   208  	// Etape 2
   209  	if !lettre.UseCoordCentre {
   210  		eqs, err := rd.SelectEquipiersByIdCamps(tx, d.camp.Id)
   211  		if err != nil {
   212  			err = shared.Rollback(tx, err)
   213  			return pub, err
   214  		}
   215  		dir, has := eqs.FindDirecteur()
   216  		if !has {
   217  			err = shared.Rollback(tx, errors.New("Aucun directeur n'est déclaré pour ce camp !"))
   218  			return pub, err
   219  		}
   220  		dirPers, err = rd.SelectPersonne(tx, dir.IdPersonne)
   221  		if err != nil {
   222  			err = shared.Rollback(tx, err)
   223  			return pub, err
   224  		}
   225  	}
   226  
   227  	pdfContent, err = pdf.LettreDirecteur(lettre, dirPers)
   228  	if err != nil {
   229  		err = shared.Rollback(tx, err)
   230  		return
   231  	}
   232  
   233  	// Etape 3
   234  	fileName = fmt.Sprintf("Lettre du directeur %s.pdf", reduitNom(d.camp.RawData().Label().String()))
   235  	docLettre, err = documents.SaveDocument(tx, docLettre.Id, pdfContent, fileName, false)
   236  	if err != nil {
   237  		err = shared.Rollback(tx, err)
   238  		return
   239  	}
   240  
   241  Publie:
   242  
   243  	// Etape4
   244  	pub, err = documents.PublieDocument(d.Signing, host, docLettre)
   245  	if err != nil {
   246  		err = shared.Rollback(tx, err)
   247  		return
   248  	}
   249  
   250  	// Etape 5
   251  	err = tx.Commit()
   252  	if err == nil {
   253  		// tout est OK, on peut lancer le nettoyage en tache de fond
   254  		go d.garbageCollectImages(lettre.Html)
   255  	}
   256  
   257  	return pub, err
   258  }
   259  
   260  func extractImagesLiens(htmlLettre string) []string {
   261  	node, err := html.Parse(strings.NewReader(htmlLettre))
   262  	if err != nil {
   263  		log.Println("error extracting images", err)
   264  		return nil
   265  	}
   266  	var (
   267  		f   func(n *html.Node)
   268  		out []string
   269  	)
   270  	f = func(n *html.Node) {
   271  		if n.Type == html.ElementNode && n.DataAtom == atom.Img {
   272  			for _, attr := range n.Attr {
   273  				if attr.Key == "src" { // found an image
   274  					parsed, err := url.Parse(attr.Val)
   275  					if err != nil {
   276  						log.Println("error parsing image url", err)
   277  						continue
   278  					}
   279  					lien := parsed.Query().Get("lien")
   280  					out = append(out, lien)
   281  				}
   282  			}
   283  		}
   284  		for c := n.FirstChild; c != nil; c = c.NextSibling {
   285  			f(c)
   286  		}
   287  	}
   288  	f(node)
   289  	return out
   290  }
   291  
   292  func (d DriverCampComplet) garbageCollectImages(htmlLettre string) {
   293  	usedLiens := extractImagesLiens(htmlLettre)
   294  	// on supprime les images non utilisées, uniquement pour le séjour courant
   295  	// car un même lien peut-être partagé entre 2 séjours
   296  	res, err := d.DB.Exec("DELETE FROM imageuploadeds WHERE id_camp = $1 AND NOT (lien = ANY($2))", d.camp.Id, pq.StringArray(usedLiens))
   297  	if err != nil {
   298  		log.Println("error removing unsued images", err)
   299  		return
   300  	}
   301  	if rowsNb, _ := res.RowsAffected(); rowsNb > 0 {
   302  		log.Printf("camp %d -> removed %d unused images\n", d.camp.Id, rowsNb)
   303  	}
   304  }