Can share survey results with a secret shared key
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
This commit is contained in:
parent
9fd73ce235
commit
fff8b821c5
1
api.go
1
api.go
|
@ -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
11
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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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("/"))
|
||||
|
|
28
surveys.go
28
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 {
|
||||
|
|
|
@ -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 = { };
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 …</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}
|
|
@ -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 :
|
||||
</p>
|
||||
<pre>{sharing_link}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Reference in New Issue