diff --git a/api.go b/api.go index a51a254..a1979d0 100644 --- a/api.go +++ b/api.go @@ -17,6 +17,7 @@ func declareAPIRoutes(router *gin.Engine) { declareAPISurveysRoutes(apiRoutes) declareAPIWorksRoutes(apiRoutes) declareAPIKeysRoutes(apiRoutes) + declareAPISharesRoutes(apiRoutes) declareCallbacksRoutes(apiRoutes) authRoutes := router.Group("") diff --git a/db.go b/db.go index b4f6799..d99acde 100644 --- a/db.go +++ b/db.go @@ -255,6 +255,17 @@ CREATE TABLE IF NOT EXISTS categories( promo MEDIUMINT NOT NULL, expand BOOLEAN NOT NULL DEFAULT FALSE ) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin; +`); err != nil { + return err + } + if _, err := db.Exec(` +CREATE TABLE IF NOT EXISTS survey_shared( + id_share INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + id_survey INTEGER NOT NULL, + secret BLOB NOT NULL, + count MEDIUMINT UNSIGNED NOT NULL DEFAULT 0, + FOREIGN KEY(id_survey) REFERENCES surveys(id_survey) +) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin; `); err != nil { return err } diff --git a/shares.go b/shares.go new file mode 100644 index 0000000..a680011 --- /dev/null +++ b/shares.go @@ -0,0 +1,187 @@ +package main + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha512" + "encoding/base64" + "fmt" + "log" + "net/http" + "net/url" + "path/filepath" + + "github.com/gin-gonic/gin" +) + +func declareAPISharesRoutes(router *gin.RouterGroup) { + surveysRoutes := router.Group("/s/surveys/:sid") + surveysRoutes.Use(surveyHandler) + surveysRoutes.Use(sharesAccessHandler) + + surveysRoutes.GET("/", func(c *gin.Context) { + share := c.MustGet("survey_share").(*SurveyShared) + share.Count += 1 + share.Update() + + c.JSON(http.StatusOK, c.MustGet("survey").(*Survey)) + }) + + surveysRoutes.GET("/questions", func(c *gin.Context) { + s := c.MustGet("survey").(*Survey) + + if questions, err := s.GetQuestions(); err != nil { + log.Println("Unable to getQuestions:", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve questions. Please try again later."}) + return + } else { + c.JSON(http.StatusOK, questions) + } + }) + + questionsRoutes := surveysRoutes.Group("/questions/:qid") + questionsRoutes.Use(questionHandler) + + questionsRoutes.GET("/", func(c *gin.Context) { + c.JSON(http.StatusOK, c.MustGet("question").(*Question)) + }) + + questionsRoutes.GET("/proposals", func(c *gin.Context) { + q := c.MustGet("question").(*Question) + + proposals, err := q.GetProposals() + if err != nil { + log.Printf("Unable to GetProposals(qid=%d): %s", q.Id, err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during proposals retrieving"}) + return + } + + c.JSON(http.StatusOK, proposals) + }) + + questionsRoutes.GET("/responses", func(c *gin.Context) { + q := c.MustGet("question").(*Question) + + res, err := q.GetResponses() + if err != nil { + log.Printf("Unable to GetResponses(qid=%d): %s", q.Id, err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during responses retrieval."}) + return + } + + c.JSON(http.StatusOK, res) + }) +} + +func sharesAccessHandler(c *gin.Context) { + s := c.MustGet("survey").(*Survey) + secret := c.Query("secret") + + shares, err := s.getShares() + if err != nil { + log.Printf("Unable to getShares(sid=%d): %s", s.Id, err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Something went wrong when authenticating the query."}) + return + } + + for _, share := range shares { + if share.Authenticate(secret) { + c.Set("survey_share", share) + c.Next() + return + } + } + + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Not authorized"}) +} + +type SurveyShared struct { + Id int64 `json:"id"` + IdSurvey int64 `json:"id_survey"` + Count int64 `json:"count"` + secret []byte +} + +func (s *Survey) Share() (*SurveyShared, error) { + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, err + } + + if res, err := DBExec("INSERT INTO survey_shared (id_survey, secret) VALUES (?, ?)", s.Id, secret); err != nil { + return nil, err + } else if sid, err := res.LastInsertId(); err != nil { + return nil, err + } else { + return &SurveyShared{sid, s.Id, 0, secret}, nil + } +} + +func (sh *SurveyShared) getMAC() []byte { + mac := hmac.New(sha512.New, sh.secret) + mac.Write([]byte(fmt.Sprintf("%d", sh.IdSurvey))) + return mac.Sum(nil) +} + +func (sh *SurveyShared) GetURL() (*url.URL, error) { + u, err := url.Parse(oidcRedirectURL) + if err != nil { + return nil, err + } + + u.Path = filepath.Join(baseURL, "results") + u.RawQuery = url.Values{ + "secret": []string{base64.RawURLEncoding.EncodeToString(sh.getMAC())}, + "survey": []string{fmt.Sprintf("%d", sh.IdSurvey)}, + }.Encode() + + return u, nil +} + +func (s *Survey) getShares() (shares []*SurveyShared, err error) { + if rows, errr := DBQuery("SELECT id_share, id_survey, secret, count FROM survey_shared"); errr != nil { + return nil, errr + } else { + defer rows.Close() + + for rows.Next() { + var sh SurveyShared + if err = rows.Scan(&sh.Id, &sh.IdSurvey, &sh.secret, &sh.Count); err != nil { + return + } + shares = append(shares, &sh) + } + if err = rows.Err(); err != nil { + return + } + + return + } +} + +func (sh *SurveyShared) Authenticate(secret string) bool { + messageMAC, err := base64.RawURLEncoding.DecodeString(secret) + if err != nil { + return false + } + + return hmac.Equal(messageMAC, sh.getMAC()) +} + +func (sh *SurveyShared) Update() (*SurveyShared, error) { + if _, err := DBExec("UPDATE survey_shared SET id_survey = ?, secret = ?, count = ? WHERE id_share = ?", sh.IdSurvey, sh.secret, sh.Count, sh.Id); err != nil { + return nil, err + } else { + return sh, err + } +} + +func (sh SurveyShared) Delete() (int64, error) { + if res, err := DBExec("DELETE FROM survey_shared WHERE id_share = ?", sh.Id); err != nil { + return 0, err + } else if nb, err := res.RowsAffected(); err != nil { + return 0, err + } else { + return nb, err + } +} diff --git a/static.go b/static.go index 8b370d9..1574f85 100644 --- a/static.go +++ b/static.go @@ -59,6 +59,7 @@ func declareStaticRoutes(router *gin.Engine) { router.GET("/grades", serveOrReverse("/")) router.GET("/help", serveOrReverse("/")) router.GET("/keys", serveOrReverse("/")) + router.GET("/results", serveOrReverse("/")) router.GET("/surveys", serveOrReverse("/")) router.GET("/surveys/*_", serveOrReverse("/")) router.GET("/users", serveOrReverse("/")) diff --git a/surveys.go b/surveys.go index 950e588..822703f 100644 --- a/surveys.go +++ b/surveys.go @@ -204,6 +204,33 @@ func declareAPIAdminSurveysRoutes(router *gin.RouterGroup) { } }) + surveysRoutes.GET("shares", func(c *gin.Context) { + survey := c.MustGet("survey").(*Survey) + + if sh, err := survey.getShares(); err != nil { + log.Println("Unable to getShares survey:", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during survey shares listing: %s", err.Error())}) + return + } else { + c.JSON(http.StatusOK, sh) + } + }) + surveysRoutes.POST("shares", func(c *gin.Context) { + survey := c.MustGet("survey").(*Survey) + + if sh, err := survey.Share(); err != nil { + log.Println("Unable to Share survey:", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during survey sharing: %s", err.Error())}) + return + } else if url, err := sh.GetURL(); err != nil { + log.Println("Unable to GetURL share:", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during survey sharing: %s", err.Error())}) + return + } else { + c.JSON(http.StatusOK, url.String()) + } + }) + declareAPIAdminAsksRoutes(surveysRoutes) declareAPIAdminDirectRoutes(surveysRoutes) declareAPIAdminQuestionsRoutes(surveysRoutes) @@ -364,6 +391,7 @@ func (s *Survey) Update() (*Survey, error) { } func (s Survey) Delete() (int64, error) { + DBExec("DELETE FROM survey_shared WHERE id_survey = ?", s.Id) if res, err := DBExec("DELETE FROM surveys WHERE id_survey = ?", s.Id); err != nil { return 0, err } else if nb, err := res.RowsAffected(); err != nil { diff --git a/ui/src/lib/components/CorrectionPieChart.svelte b/ui/src/lib/components/CorrectionPieChart.svelte index ae37d3f..ea0f9dd 100644 --- a/ui/src/lib/components/CorrectionPieChart.svelte +++ b/ui/src/lib/components/CorrectionPieChart.svelte @@ -7,7 +7,7 @@ export let question = null; function refreshProposals() { - let req = question.getProposals(); + let req = question.getProposals(secret); req.then((proposals) => { const proposal_idx = { }; @@ -17,7 +17,7 @@ proposal_idx[proposal.id] = new String(data.labels.length - 1); } - req_responses = question.getResponses(); + req_responses = question.getResponses(secret); req_responses.then((responses) => { for (const res of responses) { const rsplt = res.value.split(','); @@ -32,6 +32,7 @@ } let req_proposals = null; export let proposals = null; + export let secret = null; let req_responses = null; let mean = null; @@ -46,7 +47,7 @@ if (!proposals) { if (question.kind && (question.kind == "int" || question.kind.startsWith("list"))) { - req_responses = question.getResponses(); + req_responses = question.getResponses(secret); req_responses.then((responses) => { const values = []; const proposal_idx = { }; diff --git a/ui/src/lib/questions.js b/ui/src/lib/questions.js index 53e2115..c3a02f3 100644 --- a/ui/src/lib/questions.js +++ b/ui/src/lib/questions.js @@ -62,8 +62,10 @@ export class Question { this.kind = kind; } - async getProposals() { - const res = await fetch(`api/questions/${this.id}/proposals`, { + async getProposals(secret) { + let url = `/questions/${this.id}/proposals`; + if (secret) url = `/s/surveys/${this.id_survey}` + url + `?secret=${secret}`; + const res = await fetch('api' + url, { method: 'GET', headers: {'Accept': 'application/json'}, }); @@ -91,8 +93,10 @@ export class Question { } } - async getResponses() { - const res = await fetch(`api/surveys/${this.id_survey}/questions/${this.id}/responses`, { + async getResponses(secret) { + let url = `/surveys/${this.id_survey}/questions/${this.id}/responses`; + if (secret) url = `/s` + url + `?secret=${secret}`; + const res = await fetch('api' + url, { method: 'GET', headers: {'Accept': 'application/json'}, }); @@ -161,3 +165,17 @@ export async function getQuestions(sid) { throw new Error((await res.json()).errmsg); } } + +export async function getSharedQuestions(sid, secret) { + const res = await fetch(`api/s/surveys/${sid}/questions?secret=${secret}`, {headers: {'Accept': 'application/json'}}) + if (res.status == 200) { + const data = await res.json(); + if (data === null) { + return []; + } else { + return (data).map((q) => new Question(q)) + } + } else { + throw new Error((await res.json()).errmsg); + } +} diff --git a/ui/src/lib/surveys.js b/ui/src/lib/surveys.js index 6b01b2d..5c0c129 100644 --- a/ui/src/lib/surveys.js +++ b/ui/src/lib/surveys.js @@ -73,6 +73,18 @@ export class Survey { } } + async share() { + const res = await fetch(`api/surveys/${this.id}/shares`, { + method: 'POST', + headers: {'Accept': 'application/json'} + }); + if (res.status == 200) { + return await res.json(); + } else { + throw new Error((await res.json()).errmsg); + } + } + async save() { const res = await fetch(this.id?`api/surveys/${this.id}`:'api/surveys', { method: this.id?'PUT':'POST', @@ -190,3 +202,12 @@ export async function getSurvey(sid) { throw new Error((await res.json()).errmsg); } } + +export async function getSharedSurvey(sid, secret) { + const res = await fetch(`api/s/surveys/${sid}?secret=${secret}`, {headers: {'Accept': 'application/json'}}) + if (res.status == 200) { + return new Survey(await res.json()); + } else { + throw new Error((await res.json()).errmsg); + } +} diff --git a/ui/src/routes/results.svelte b/ui/src/routes/results.svelte new file mode 100644 index 0000000..21cff4a --- /dev/null +++ b/ui/src/routes/results.svelte @@ -0,0 +1,61 @@ + + + + +{#await surveyP then survey} +
+

+ {survey.title} + Réponses +

+ +
+ + {#await getSharedQuestions(survey.id, secret)} +
+
+ Chargement des questions … +
+ {:then questions} + {#each questions as question (question.id)} +

{question.title}

+ {#if question.kind == "text" || (exportview_list && question.kind.indexOf("list") == 0)} + {#await question.getResponses(secret) then responses} + {#each responses as response (response.id)} +
+
+

+ {response.value} +

+
+
+ {/each} + {/await} + {:else} + + {/if} +
+ {/each} + {/await} +{/await} diff --git a/ui/src/routes/surveys/[sid]/responses/index.svelte b/ui/src/routes/surveys/[sid]/responses/index.svelte index 3ceec3b..a986180 100644 --- a/ui/src/routes/surveys/[sid]/responses/index.svelte +++ b/ui/src/routes/surveys/[sid]/responses/index.svelte @@ -30,6 +30,15 @@ let edit = false; let exportview = false; let exportview_list = false; + + let sharing_link = null; + async function shareResults(survey) { + const res = await survey.share(); + sharing_link = res; + + const modal = new bootstrap.Modal(document.getElementById('shareModal')); + modal.show(); + } {#await surveyP then survey} @@ -40,6 +49,11 @@ class="ms-1 float-end" on:update={() => goto(`surveys/${survey.id}/admin`)} /> + + + + + +