Can share survey results with a secret shared key
continuous-integration/drone/push Build is passing Details

This commit is contained in:
nemunaire 2022-12-02 11:48:10 +01:00
parent 9fd73ce235
commit fff8b821c5
10 changed files with 367 additions and 7 deletions

1
api.go
View File

@ -17,6 +17,7 @@ func declareAPIRoutes(router *gin.Engine) {
declareAPISurveysRoutes(apiRoutes)
declareAPIWorksRoutes(apiRoutes)
declareAPIKeysRoutes(apiRoutes)
declareAPISharesRoutes(apiRoutes)
declareCallbacksRoutes(apiRoutes)
authRoutes := router.Group("")

11
db.go
View File

@ -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
}

187
shares.go Normal file
View File

@ -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
}
}

View File

@ -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("/"))

View File

@ -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 {

View File

@ -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 = { };

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,61 @@
<script context="module">
export async function load({ url }) {
return {
props: {
secret: url.searchParams.get("secret"),
idsurvey: url.searchParams.get("survey"),
exportview_list: url.searchParams.get("graph_list")?false:true,
}
};
}
</script>
<script>
import { getSharedQuestions } from '$lib/questions';
import { getSharedSurvey } from '$lib/surveys';
import CorrectionPieChart from '$lib/components/CorrectionPieChart.svelte';
import SurveyBadge from '$lib/components/SurveyBadge.svelte';
export let secret;
export let idsurvey;
let surveyP = getSharedSurvey(idsurvey, secret);
export let exportview_list = true;
</script>
{#await surveyP then survey}
<div class="d-flex align-items-center">
<h2>
{survey.title}
<small class="text-muted">Réponses</small>
</h2>
<SurveyBadge class="ms-2" {survey} />
</div>
{#await getSharedQuestions(survey.id, secret)}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des questions &hellip;</span>
</div>
{:then questions}
{#each questions as question (question.id)}
<h3>{question.title}</h3>
{#if question.kind == "text" || (exportview_list && question.kind.indexOf("list") == 0)}
{#await question.getResponses(secret) then responses}
{#each responses as response (response.id)}
<div class="card mb-2">
<div class="card-body">
<p class="card-text" style:white-space="pre-line">
{response.value}
</p>
</div>
</div>
{/each}
{/await}
{:else}
<CorrectionPieChart {question} {secret} />
{/if}
<hr class="mb-3">
{/each}
{/await}
{/await}

View File

@ -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();
}
</script>
{#await surveyP then survey}
@ -40,6 +49,11 @@
class="ms-1 float-end"
on:update={() => goto(`surveys/${survey.id}/admin`)}
/>
<button
class="btn btn-outline-dark ms-1 float-end"
title="Partager les résultats"
on:click={() => shareResults(survey)}
><i class="bi bi-share-fill"></i></button>
<button
class="btn ms-1 float-end"
class:btn-dark={exportview}
@ -144,3 +158,20 @@
{/if}
{/await}
{/await}
<div class="modal fade" tabindex="-1" id="shareModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Partage de résultats</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
Voici le lien de partage des résultats de ce sondage&nbsp;:
</p>
<pre>{sharing_link}</pre>
</div>
</div>
</div>
</div>