From 1969dc43629a577eea63301c7d512113617aa977 Mon Sep 17 00:00:00 2001 From: Yann Bizeul Date: Thu, 5 Sep 2024 22:37:08 +0200 Subject: [PATCH] first implementation of messages templates --- .../MarkdownEditor/MarkdownEditor.tsx | 9 +- html/src/Components/ShareEditor.tsx | 54 ++++++-- html/src/hupload.ts | 4 + hupload/go.mod | 2 +- hupload/handlers.go | 29 ++++ hupload/handlers_test.go | 127 +++++++++++++++++- hupload/handlers_testdata/config.yml | 6 +- hupload/internal/config/config.go | 7 +- hupload/internal/config/config_test.go | 6 + .../config/config_testdata/config.yml | 4 + hupload/internal/config/messages.go | 6 + .../pkg/apiws/middleware/auth/auth_test.go | 2 +- hupload/server.go | 16 ++- 13 files changed, 246 insertions(+), 26 deletions(-) create mode 100644 hupload/internal/config/messages.go diff --git a/html/src/Components/MarkdownEditor/MarkdownEditor.tsx b/html/src/Components/MarkdownEditor/MarkdownEditor.tsx index 8524394..18acae7 100644 --- a/html/src/Components/MarkdownEditor/MarkdownEditor.tsx +++ b/html/src/Components/MarkdownEditor/MarkdownEditor.tsx @@ -7,15 +7,14 @@ import { FullHeightTextArea } from "./FullHeightTextArea"; interface MarkDownEditorProps { onChange: (message: string) => void; - markdown: string; } - export function MarkDownEditor(props: MarkDownEditorProps&BoxComponentProps) { + export function MarkDownEditor(props: MarkDownEditorProps&BoxComponentProps&{children: string}) { // Initialize props - const { onChange, markdown } = props; + const { onChange, children } = props; // Initialize state - const [_markdown, setMarkdown] = useState(markdown); + const [markdown, setMarkdown] = useState(children); const [preview, previewH] = useDisclosure(false); // Functions @@ -32,7 +31,7 @@ interface MarkDownEditorProps { {preview? - + : diff --git a/html/src/Components/ShareEditor.tsx b/html/src/Components/ShareEditor.tsx index a961482..59169eb 100644 --- a/html/src/Components/ShareEditor.tsx +++ b/html/src/Components/ShareEditor.tsx @@ -1,10 +1,11 @@ -import { Share } from "@/hupload"; -import { ActionIcon, Box, BoxComponentProps, Button, Flex, Input, NumberInput, rem, SegmentedControl, Stack, TextInput, useMantineTheme } from "@mantine/core"; -import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; +import { Message, Share } from "@/hupload"; +import { ActionIcon, Box, BoxComponentProps, Button, Flex, Input, Menu, NumberInput, rem, SegmentedControl, Stack, TextInput, useMantineTheme } from "@mantine/core"; +import { IconChevronLeft, IconChevronRight, IconListCheck } from "@tabler/icons-react"; import { useDisclosure, useMediaQuery } from "@mantine/hooks"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import classes from './ShareEditor.module.css'; import { MarkDownEditor } from "./MarkdownEditor"; +import { H } from "@/APIClient"; interface ShareEditorProps { onChange: (options: Share["options"]) => void; @@ -20,12 +21,26 @@ export function ShareEditor(props: ShareEditorProps&BoxComponentProps) { // Initialize state const [_options, setOptions] = useState(options) + const [messages, setMessages] = useState([]) // Initialize hooks const [showMessage, showMessageH ] = useDisclosure(false); const theme = useMantineTheme() const isInBrowser = useMediaQuery('(min-width: +' + theme.breakpoints.xs + ')'); + // effects + useEffect(() => { + H.get('/messages').then((res) => { + setMessages(res as string[]) + }) + },[]) + + const selectMessage = (index: number) => { + H.get('/messages/'+index).then((res) => { + const m = res as Message + notifyChange({..._options, message: m.message as string}) + }) + } // Functions const notifyChange = (o: Share["options"]) => { setOptions(o) @@ -82,12 +97,31 @@ export function ShareEditor(props: ShareEditorProps&BoxComponentProps) { {/* Right section */} {(showMessage||!isInBrowser)&& - { notifyChange({..._options, message:v}); }} - markdown={_options.message?_options.message:""} - /> + <> + { notifyChange({..._options, message:v}); }} + > + {_options.message?_options.message:""} + + {messages.length>0&& + + + + + + + + {messages.map((m, i) => ( + {selectMessage(i+1)}}> + {m} + + ))} + + + } + } diff --git a/html/src/hupload.ts b/html/src/hupload.ts index c52738f..81c4560 100644 --- a/html/src/hupload.ts +++ b/html/src/hupload.ts @@ -26,6 +26,10 @@ export interface ItemInfo { Size: number; } +export interface Message { + title: string; + message: string; +} // Utilities diff --git a/hupload/go.mod b/hupload/go.mod index 3fac329..5be439e 100644 --- a/hupload/go.mod +++ b/hupload/go.mod @@ -14,6 +14,7 @@ require ( github.com/coreos/go-oidc v2.2.1+incompatible golang.org/x/crypto v0.25.0 golang.org/x/oauth2 v0.21.0 + gopkg.in/square/go-jose.v2 v2.6.0 ) require ( @@ -34,5 +35,4 @@ require ( github.com/aws/smithy-go v1.20.4 // indirect github.com/pquerna/cachecontrol v0.2.0 // indirect github.com/stretchr/testify v1.8.2 // indirect - gopkg.in/square/go-jose.v2 v2.6.0 // indirect ) diff --git a/hupload/handlers.go b/hupload/handlers.go index 038e982..cca8f75 100644 --- a/hupload/handlers.go +++ b/hupload/handlers.go @@ -385,6 +385,35 @@ func (h *Hupload) postLogin(w http.ResponseWriter, r *http.Request) { writeSuccessJSON(w, u) } +func (h *Hupload) getMessages(w http.ResponseWriter, r *http.Request) { + titles := []string{} + + for _, m := range h.Config.Values.MessageTemplates { + titles = append(titles, m.Title) + } + + writeSuccessJSON(w, titles) +} + +var ErrMessageInvalidIndex = errors.New("invalid index") +var ErrMessageIndexOutOfBounds = errors.New("index out of bounds") + +func (h *Hupload) getMessage(w http.ResponseWriter, r *http.Request) { + index, err := strconv.Atoi(r.PathValue("index")) + if err != nil { + writeError(w, http.StatusBadRequest, ErrMessageInvalidIndex.Error()) + return + } + t := h.Config.Values.MessageTemplates + if len(t) <= index && index > 0 { + writeSuccessJSON(w, t[index-1]) + return + } else { + writeError(w, http.StatusBadRequest, ErrMessageIndexOutOfBounds.Error()) + return + } +} + // getVersion returns hupload version func (h *Hupload) getVersion(w http.ResponseWriter, r *http.Request) { v := struct { diff --git a/hupload/handlers_test.go b/hupload/handlers_test.go index f3d5712..a61aa6c 100644 --- a/hupload/handlers_test.go +++ b/hupload/handlers_test.go @@ -109,7 +109,7 @@ func TestCreateShare(t *testing.T) { return } - got := string(w.Body.Bytes()) + got := string(w.Body.String()) want := `{"errors":["JWTAuthMiddleware: no Authorization header"]}` if want != got { @@ -1492,6 +1492,131 @@ func TestDeleteItem(t *testing.T) { }) } } + +func TestMessages(t *testing.T) { + c := &config.Config{ + Path: "handlers_testdata/config.yml", + } + + h := getHupload(t, c) + + t.Run("Get messages should work", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v1/messages", nil) + + req.SetBasicAuth("admin", "hupload") + + w := httptest.NewRecorder() + + h.API.Mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + return + } + + got := []string{} + _ = json.NewDecoder(w.Body).Decode(&got) + + want := []string{"Message title"} + + if !reflect.DeepEqual(got, want) { + t.Errorf("Expected %v, got %v", want, got) + return + } + }) + + t.Run("Get message without auth should fail", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v1/messages", nil) + + w := httptest.NewRecorder() + + h.API.Mux.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + return + } + + type btype struct { + Errors []string `json:"errors"` + } + + got := btype{} + + _ = json.NewDecoder(w.Body).Decode(&got) + + want := btype{ + Errors: []string{"JWTAuthMiddleware: no Authorization header"}, + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("Expected %v, got %v", want, got) + return + } + }) + + t.Run("Get message should work", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v1/messages/1", nil) + + req.SetBasicAuth("admin", "hupload") + + w := httptest.NewRecorder() + + h.API.Mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + return + } + + var got *config.MessageTemplate + + _ = json.NewDecoder(w.Body).Decode(&got) + + want := &config.MessageTemplate{ + Title: "Message title", + Message: "Message content", + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("Expected %v, got %v", want, got) + return + } + }) + + t.Run("Get message without auth should fail", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v1/messages/1", nil) + + req.SetBasicAuth("admin", "hupload") + + w := httptest.NewRecorder() + + h.API.Mux.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + return + } + + type btype struct { + Errors []string `json:"errors"` + } + + got := btype{} + + _ = json.NewDecoder(w.Body).Decode(&got) + + want := btype{ + Errors: []string{"JWTAuthMiddleware: no Authorization header"}, + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("Expected %v, got %v", want, got) + return + } + }) +} + func TestVersion(t *testing.T) { t.Cleanup(func() { os.RemoveAll("tmptest") diff --git a/hupload/handlers_testdata/config.yml b/hupload/handlers_testdata/config.yml index c5ac04d..020d609 100644 --- a/hupload/handlers_testdata/config.yml +++ b/hupload/handlers_testdata/config.yml @@ -8,4 +8,8 @@ storage: auth: type: file options: - path: handlers_testdata/users.yml \ No newline at end of file + path: handlers_testdata/users.yml +messages: + - title: Message title + message: | + Message content \ No newline at end of file diff --git a/hupload/internal/config/config.go b/hupload/internal/config/config.go index 7a0443d..3700a6a 100644 --- a/hupload/internal/config/config.go +++ b/hupload/internal/config/config.go @@ -29,9 +29,10 @@ type TypeOptions struct { // ConfigValues.Title. type ConfigValues struct { Title string - DefaultValidityDays int `yaml:"availability_days"` - Storage TypeOptions `yaml:"storage"` - Authentication TypeOptions `yaml:"auth"` + DefaultValidityDays int `yaml:"availability_days"` + Storage TypeOptions `yaml:"storage"` + Authentication TypeOptions `yaml:"auth"` + MessageTemplates []MessageTemplate `yaml:"messages"` } // Config is the internal representation of Hupload configuration file at path diff --git a/hupload/internal/config/config_test.go b/hupload/internal/config/config_test.go index 14f51f6..f784b2f 100644 --- a/hupload/internal/config/config_test.go +++ b/hupload/internal/config/config_test.go @@ -81,6 +81,12 @@ func TestLoadGoodConfig(t *testing.T) { "path": "config_testdata/users.yml", }, }, + MessageTemplates: []MessageTemplate{ + { + Title: "Message title", + Message: "Message content", + }, + }, } got := c.Values diff --git a/hupload/internal/config/config_testdata/config.yml b/hupload/internal/config/config_testdata/config.yml index 73ebe7f..0dfe584 100644 --- a/hupload/internal/config/config_testdata/config.yml +++ b/hupload/internal/config/config_testdata/config.yml @@ -10,3 +10,7 @@ storage: path: data max_file_mb: 500 max_share_mb: 2000 +messages: + - title: Message title + message: | + Message content \ No newline at end of file diff --git a/hupload/internal/config/messages.go b/hupload/internal/config/messages.go new file mode 100644 index 0000000..6d876ec --- /dev/null +++ b/hupload/internal/config/messages.go @@ -0,0 +1,6 @@ +package config + +type MessageTemplate struct { + Title string `yaml:"title" json:"title"` + Message string `yaml:"message" json:"message"` +} diff --git a/hupload/pkg/apiws/middleware/auth/auth_test.go b/hupload/pkg/apiws/middleware/auth/auth_test.go index d488a59..73e532b 100644 --- a/hupload/pkg/apiws/middleware/auth/auth_test.go +++ b/hupload/pkg/apiws/middleware/auth/auth_test.go @@ -21,7 +21,7 @@ func TestServeNextAuthenticated(t *testing.T) { s := r.Context().Value(authentication.AuthStatusKey).(authentication.AuthStatus) c := s.Error if c != nil { - t.Errorf("Expected nil, got %v", c.(error)) + t.Errorf("Expected nil, got %v", c) } if !s.Authenticated { t.Errorf("Expected AuthStatusSuccess, got %v", c) diff --git a/hupload/server.go b/hupload/server.go index 83e4c51..ca91376 100644 --- a/hupload/server.go +++ b/hupload/server.go @@ -80,21 +80,29 @@ func (h *Hupload) setup() { // That's Hupload principle, the security is based on the share name // which is usually a random string. - api.AddPublicRoute("POST /api/v1/shares/{share}/items/{item}", authenticator, h.postItem) - api.AddPublicRoute("GET /api/v1/shares/{share}/items", authenticator, h.getShareItems) api.AddPublicRoute("GET /api/v1/shares/{share}", authenticator, h.getShare) + api.AddPublicRoute("GET /api/v1/shares/{share}/items", authenticator, h.getShareItems) api.AddPublicRoute("GET /api/v1/shares/{share}/items/{item}", authenticator, h.getItem) - api.AddPublicRoute("GET /d/{share}/{item}", authenticator, h.getItem) + + api.AddPublicRoute("POST /api/v1/shares/{share}/items/{item}", authenticator, h.postItem) api.AddPublicRoute("DELETE /api/v1/shares/{share}/items/{item}", authenticator, h.deleteItem) + api.AddPublicRoute("GET /d/{share}/{item}", authenticator, h.getItem) + // Protected routes + api.AddRoute("GET /login", authenticator, h.postLogin) api.AddRoute("POST /login", authenticator, h.postLogin) + + api.AddRoute("GET /api/v1/shares", authenticator, h.getShares) api.AddRoute("POST /api/v1/shares", authenticator, h.postShare) api.AddRoute("POST /api/v1/shares/{share}", authenticator, h.postShare) api.AddRoute("PATCH /api/v1/shares/{share}", authenticator, h.patchShare) api.AddRoute("DELETE /api/v1/shares/{share}", authenticator, h.deleteShare) - api.AddRoute("GET /api/v1/shares", authenticator, h.getShares) + + api.AddRoute("GET /api/v1/messages/{index}", authenticator, h.getMessage) + api.AddRoute("GET /api/v1/messages", authenticator, h.getMessages) + api.AddRoute("GET /api/v1/version", authenticator, h.getVersion) api.AddRoute("GET /api/v1/*", authenticator, func(w http.ResponseWriter, r *http.Request) {