diff --git a/.gitignore b/.gitignore index e610cac..4db9215 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ users/ .vscode/ *.swp + + +*.db \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index ebfde9a..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "editor.formatOnSave": true, -} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e75fe32..8d9b4c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ ## ## Build Container ## -FROM golang:1.19-alpine as build +FROM golang:1.20-alpine as build RUN apk add --no-cache git @@ -21,7 +21,7 @@ RUN go build -o ./out/owl ./cmd/owl ## ## Run Container ## -FROM alpine:3.9 +FROM alpine RUN apk add ca-certificates COPY --from=build /tmp/owl/out/ /bin/ diff --git a/README.md b/README.md index d7aa93d..b41d09c 100644 --- a/README.md +++ b/README.md @@ -6,117 +6,3 @@ A simple web server for blogs generated from Markdown files. **_This project is not yet stable. Expect frequent breaking changes! Only use this if you are willing to regularly adjust your project accordingly._** -## Repository - -A repository holds all data for a web server. It contains multiple users. - -## User - -A user has a collection of posts. -Each directory in the `/users/` directory of a repository is considered a user. - -### User Directory structure - -``` -/ - \- public/ - \- - \- index.md - -- This will be rendered as the blog post. - -- Must be present for the blog post to be valid. - -- All other folders will be ignored - \- incoming_webmentions.yml - -- Used to track incoming webmentions - \- outgoing_webmentions.yml - -- Used to track outgoing webmentions - \- media/ - -- Contains all media files used in the blog post. - -- All files in this folder will be publicly available - \- webmention/ - \- .yml - -- Contains data for a received webmention - \- meta/ - \- base.html - -- The template used to render all sites - \- config.yml - -- Holds information about the user - \- VERSION - -- Contains the version string. - -- Used to determine compatibility in the future - \- media/ - -- All this files will be publicly available. To be used for general files - \- avatar.{png|jpg|jpeg|gif} - -- Optional: Avatar to be used in various places - \- favicon.{png|jpg|jpeg|gif|ico} - -- Optional: Favicon for the site -``` - -### User Config - -Stored in `meta/config.yml` - -``` -title: "Title of the Blog" -subtitle: "Subtitle of the Blog" -header_color: "#ff0000" -author_name: "Your Name" -me: - - name: "Connect on Mastodon" - url: "https://chaos.social/@h4kor" - - name: "I'm on Twitter" - url: "https://twitter.com/h4kor" -``` - -### Post - -Posts are Markdown files with a mandatory metadata head. - -- The `title` will be added to the web page and does not have to be reapeated in the body. It will be used in any lists of posts. -- `description` is optional. At the moment this is only used for the HTML head meta data. -- `aliases` are optional. They are used as permanent redirects to the actual blog page. -- `draft` is false by default. If set to `true` the post will not be accessible. -- `reply` optional. Will add the link to the top of the post with `rel="in-reply-to"`. For more infos see: [https://indieweb.org/reply](https://indieweb.org/reply) - -``` ---- -title: My new Post -Description: Short text used in meta data (and lists in the future) -date: 13 Aug 2022 17:07 UTC -aliases: - - /my/new/post - - /old_blog_path/ -draft: false -reply: - url: https://link.to/referred_post - text: Text used for link ---- - -Actual post - -``` - -### Webmentions - -This feature is not yet full supported and needs a lot of manual work. Expect this to change quiet frequently and breaking existing usages. - -To send webmentions use the command `owl webmention` - -Retrieved webmentions have to be approved manually by changing the `approval_status` in the `incoming_webmentions.yml` file. - -#### incoming_webmentions.yml - -``` -- source: https://example.com/post - title: Example Post - approval_status: ["", "approved", "rejected"] - retrieved_at: 2021-08-13T17:07:00Z -``` - -#### outgoing_webmentions.yml - -``` -- target: https://example.com/post - supported: true - scanned_at: 2021-08-13T17:07:00Z - last_sent_at: 2021-08-13T17:07:00Z -``` diff --git a/app/author_service.go b/app/author_service.go new file mode 100644 index 0000000..93d52c8 --- /dev/null +++ b/app/author_service.go @@ -0,0 +1,88 @@ +package app + +import ( + "crypto/sha256" + "fmt" + "owl-blogs/app/repository" + "owl-blogs/config" + "owl-blogs/domain/model" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +type AuthorService struct { + repo repository.AuthorRepository + siteConfigRepo repository.ConfigRepository +} + +func NewAuthorService(repo repository.AuthorRepository, siteConfigRepo repository.ConfigRepository) *AuthorService { + return &AuthorService{repo: repo, siteConfigRepo: siteConfigRepo} +} + +func hashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 10) + if err != nil { + return "", err + } + return string(bytes), nil +} + +func (s *AuthorService) Create(name string, password string) (*model.Author, error) { + hash, err := hashPassword(password) + if err != nil { + return nil, err + } + return s.repo.Create(name, hash) +} + +func (s *AuthorService) FindByName(name string) (*model.Author, error) { + return s.repo.FindByName(name) +} + +func (s *AuthorService) Authenticate(name string, password string) bool { + author, err := s.repo.FindByName(name) + if err != nil { + return false + } + err = bcrypt.CompareHashAndPassword([]byte(author.PasswordHash), []byte(password)) + return err == nil +} + +func (s *AuthorService) getSecretKey() string { + siteConfig := model.SiteConfig{} + err := s.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + if err != nil { + panic(err) + } + if siteConfig.Secret == "" { + siteConfig.Secret = RandStringRunes(64) + err = s.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) + if err != nil { + panic(err) + } + } + return siteConfig.Secret +} + +func (s *AuthorService) CreateToken(name string) (string, error) { + hash := sha256.New() + _, err := hash.Write([]byte(name + s.getSecretKey())) + if err != nil { + return "", err + } + return fmt.Sprintf("%s.%x", name, hash.Sum(nil)), nil +} + +func (s *AuthorService) ValidateToken(token string) (bool, string) { + parts := strings.Split(token, ".") + witness := parts[len(parts)-1] + name := strings.Join(parts[:len(parts)-1], ".") + + hash := sha256.New() + _, err := hash.Write([]byte(name + s.getSecretKey())) + if err != nil { + return false, "" + } + return fmt.Sprintf("%x", hash.Sum(nil)) == witness, name +} diff --git a/app/author_service_test.go b/app/author_service_test.go new file mode 100644 index 0000000..856a920 --- /dev/null +++ b/app/author_service_test.go @@ -0,0 +1,95 @@ +package app_test + +import ( + "owl-blogs/app" + "owl-blogs/domain/model" + "owl-blogs/infra" + "owl-blogs/test" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type testConfigRepo struct { + config model.SiteConfig +} + +// Get implements repository.SiteConfigRepository. +func (c *testConfigRepo) Get(name string, result interface{}) error { + *result.(*model.SiteConfig) = c.config + return nil +} + +// Update implements repository.SiteConfigRepository. +func (c *testConfigRepo) Update(name string, result interface{}) error { + c.config = result.(model.SiteConfig) + return nil +} + +func getAutherService() *app.AuthorService { + db := test.NewMockDb() + authorRepo := infra.NewDefaultAuthorRepo(db) + authorService := app.NewAuthorService(authorRepo, &testConfigRepo{}) + return authorService + +} + +func TestAuthorCreate(t *testing.T) { + authorService := getAutherService() + author, err := authorService.Create("test", "test") + require.NoError(t, err) + require.Equal(t, "test", author.Name) + require.NotEmpty(t, author.PasswordHash) + require.NotEqual(t, "test", author.PasswordHash) +} + +func TestAuthorFindByName(t *testing.T) { + authorService := getAutherService() + _, err := authorService.Create("test", "test") + require.NoError(t, err) + author, err := authorService.FindByName("test") + require.NoError(t, err) + require.Equal(t, "test", author.Name) + require.NotEmpty(t, author.PasswordHash) + require.NotEqual(t, "test", author.PasswordHash) +} + +func TestAuthorAuthenticate(t *testing.T) { + authorService := getAutherService() + _, err := authorService.Create("test", "test") + require.NoError(t, err) + require.True(t, authorService.Authenticate("test", "test")) + require.False(t, authorService.Authenticate("test", "test1")) + require.False(t, authorService.Authenticate("test1", "test")) +} + +func TestAuthorCreateToken(t *testing.T) { + authorService := getAutherService() + _, err := authorService.Create("test", "test") + require.NoError(t, err) + token, err := authorService.CreateToken("test") + require.NoError(t, err) + require.NotEmpty(t, token) + require.NotEqual(t, "test", token) +} + +func TestAuthorValidateToken(t *testing.T) { + authorService := getAutherService() + _, err := authorService.Create("test", "test") + require.NoError(t, err) + token, err := authorService.CreateToken("test") + require.NoError(t, err) + + valid, name := authorService.ValidateToken(token) + require.True(t, valid) + require.Equal(t, "test", name) + valid, _ = authorService.ValidateToken(token[:len(token)-2]) + require.False(t, valid) + valid, _ = authorService.ValidateToken("test") + require.False(t, valid) + valid, _ = authorService.ValidateToken("test.test") + require.False(t, valid) + valid, _ = authorService.ValidateToken(strings.Replace(token, "test", "test1", 1)) + require.False(t, valid) +} diff --git a/app/binary_service.go b/app/binary_service.go new file mode 100644 index 0000000..d459cd8 --- /dev/null +++ b/app/binary_service.go @@ -0,0 +1,26 @@ +package app + +import ( + "owl-blogs/app/repository" + "owl-blogs/domain/model" +) + +type BinaryService struct { + repo repository.BinaryRepository +} + +func NewBinaryFileService(repo repository.BinaryRepository) *BinaryService { + return &BinaryService{repo: repo} +} + +func (s *BinaryService) Create(name string, file []byte) (*model.BinaryFile, error) { + return s.repo.Create(name, file, nil) +} + +func (s *BinaryService) CreateEntryFile(name string, file []byte, entry model.Entry) (*model.BinaryFile, error) { + return s.repo.Create(name, file, entry) +} + +func (s *BinaryService) FindById(id string) (*model.BinaryFile, error) { + return s.repo.FindById(id) +} diff --git a/app/entry_register.go b/app/entry_register.go new file mode 100644 index 0000000..90c4e7e --- /dev/null +++ b/app/entry_register.go @@ -0,0 +1,58 @@ +package app + +import ( + "errors" + "owl-blogs/domain/model" + "reflect" +) + +type EntryTypeRegistry struct { + types map[string]model.Entry +} + +func NewEntryTypeRegistry() *EntryTypeRegistry { + return &EntryTypeRegistry{types: map[string]model.Entry{}} +} + +func (r *EntryTypeRegistry) entryType(entry model.Entry) string { + return reflect.TypeOf(entry).Elem().Name() +} + +func (r *EntryTypeRegistry) Register(entry model.Entry) error { + t := r.entryType(entry) + if _, ok := r.types[t]; ok { + return errors.New("entry type already registered") + } + r.types[t] = entry + return nil +} + +func (r *EntryTypeRegistry) Types() []model.Entry { + types := []model.Entry{} + for _, t := range r.types { + types = append(types, t) + } + return types +} + +func (r *EntryTypeRegistry) TypeName(entry model.Entry) (string, error) { + t := r.entryType(entry) + if _, ok := r.types[t]; !ok { + return "", errors.New("entry type not registered") + } + return t, nil +} + +func (r *EntryTypeRegistry) Type(name string) (model.Entry, error) { + if _, ok := r.types[name]; !ok { + return nil, errors.New("entry type not registered") + } + + val := reflect.ValueOf(r.types[name]) + if val.Kind() == reflect.Ptr { + val = reflect.Indirect(val) + } + newEntry := reflect.New(val.Type()).Interface().(model.Entry) + + return newEntry, nil +} diff --git a/app/entry_register_test.go b/app/entry_register_test.go new file mode 100644 index 0000000..47775d1 --- /dev/null +++ b/app/entry_register_test.go @@ -0,0 +1,23 @@ +package app_test + +import ( + "owl-blogs/app" + "owl-blogs/test" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRegistryTypeNameNotExisting(t *testing.T) { + register := app.NewEntryTypeRegistry() + _, err := register.TypeName(&test.MockEntry{}) + require.Error(t, err) +} + +func TestRegistryTypeName(t *testing.T) { + register := app.NewEntryTypeRegistry() + register.Register(&test.MockEntry{}) + name, err := register.TypeName(&test.MockEntry{}) + require.NoError(t, err) + require.Equal(t, "MockEntry", name) +} diff --git a/app/entry_service.go b/app/entry_service.go new file mode 100644 index 0000000..3528c4e --- /dev/null +++ b/app/entry_service.go @@ -0,0 +1,54 @@ +package app + +import ( + "owl-blogs/app/repository" + "owl-blogs/domain/model" +) + +type EntryService struct { + EntryRepository repository.EntryRepository +} + +func NewEntryService(entryRepository repository.EntryRepository) *EntryService { + return &EntryService{EntryRepository: entryRepository} +} + +func (s *EntryService) Create(entry model.Entry) error { + return s.EntryRepository.Create(entry) +} + +func (s *EntryService) Update(entry model.Entry) error { + return s.EntryRepository.Update(entry) +} + +func (s *EntryService) Delete(entry model.Entry) error { + return s.EntryRepository.Delete(entry) +} + +func (s *EntryService) FindById(id string) (model.Entry, error) { + return s.EntryRepository.FindById(id) +} + +func (s *EntryService) filterEntries(entries []model.Entry, published bool, drafts bool) []model.Entry { + filteredEntries := make([]model.Entry, 0) + for _, entry := range entries { + if published && entry.PublishedAt() != nil && !entry.PublishedAt().IsZero() { + filteredEntries = append(filteredEntries, entry) + } + if drafts && (entry.PublishedAt() == nil || entry.PublishedAt().IsZero()) { + filteredEntries = append(filteredEntries, entry) + } + } + return filteredEntries +} + +func (s *EntryService) FindAllByType(types *[]string, published bool, drafts bool) ([]model.Entry, error) { + entries, err := s.EntryRepository.FindAll(types) + return s.filterEntries(entries, published, drafts), err + +} + +func (s *EntryService) FindAll() ([]model.Entry, error) { + entries, err := s.EntryRepository.FindAll(nil) + return s.filterEntries(entries, true, true), err +} diff --git a/app/repository/interfaces.go b/app/repository/interfaces.go new file mode 100644 index 0000000..f57c5b7 --- /dev/null +++ b/app/repository/interfaces.go @@ -0,0 +1,36 @@ +package repository + +import ( + "owl-blogs/domain/model" +) + +type EntryRepository interface { + Create(entry model.Entry) error + Update(entry model.Entry) error + Delete(entry model.Entry) error + FindById(id string) (model.Entry, error) + FindAll(types *[]string) ([]model.Entry, error) +} + +type BinaryRepository interface { + // Create creates a new binary file + // The name is the original file name, and is not unique + // BinaryFile.Id is a unique identifier + Create(name string, data []byte, entry model.Entry) (*model.BinaryFile, error) + FindById(id string) (*model.BinaryFile, error) + FindByNameForEntry(name string, entry model.Entry) (*model.BinaryFile, error) +} + +type AuthorRepository interface { + // Create creates a new author + // It returns an error if the name is already taken + Create(name string, passwordHash string) (*model.Author, error) + // FindByName finds an author by name + // It returns an error if the author is not found + FindByName(name string) (*model.Author, error) +} + +type ConfigRepository interface { + Get(name string, config interface{}) error + Update(name string, siteConfig interface{}) error +} diff --git a/app/utils.go b/app/utils.go new file mode 100644 index 0000000..2797526 --- /dev/null +++ b/app/utils.go @@ -0,0 +1,15 @@ +package app + +import ( + "math/rand" +) + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +func RandStringRunes(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} diff --git a/auth_test.go b/auth_test.go deleted file mode 100644 index 5efd35f..0000000 --- a/auth_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package owl_test - -import ( - "h4kor/owl-blogs" - "h4kor/owl-blogs/test/assertions" - "net/http" - "testing" -) - -func TestGetRedirctUrisLink(t *testing.T) { - html := []byte("") - parser := &owl.OwlHtmlParser{} - uris, err := parser.GetRedirctUris(constructResponse(html)) - - assertions.AssertNoError(t, err, "Unable to parse feed") - - assertions.AssertArrayContains(t, uris, "http://example.com/redirect") -} - -func TestGetRedirctUrisLinkMultiple(t *testing.T) { - html := []byte(` - - - - - - `) - parser := &owl.OwlHtmlParser{} - uris, err := parser.GetRedirctUris(constructResponse(html)) - - assertions.AssertNoError(t, err, "Unable to parse feed") - - assertions.AssertArrayContains(t, uris, "http://example.com/redirect1") - assertions.AssertArrayContains(t, uris, "http://example.com/redirect2") - assertions.AssertArrayContains(t, uris, "http://example.com/redirect3") - assertions.AssertLen(t, uris, 3) -} - -func TestGetRedirectUrisLinkHeader(t *testing.T) { - html := []byte("") - parser := &owl.OwlHtmlParser{} - resp := constructResponse(html) - resp.Header = http.Header{"Link": []string{"; rel=\"redirect_uri\""}} - uris, err := parser.GetRedirctUris(resp) - - assertions.AssertNoError(t, err, "Unable to parse feed") - assertions.AssertArrayContains(t, uris, "http://example.com/redirect") -} diff --git a/cmd/owl/create_author.go b/cmd/owl/create_author.go new file mode 100644 index 0000000..d5f2a88 --- /dev/null +++ b/cmd/owl/create_author.go @@ -0,0 +1,29 @@ +package main + +import ( + "owl-blogs/infra" + + "github.com/spf13/cobra" +) + +var user string +var password string + +func init() { + rootCmd.AddCommand(newAuthorCmd) + + newAuthorCmd.Flags().StringVarP(&user, "user", "u", "", "The user name") + newAuthorCmd.MarkFlagRequired("user") + newAuthorCmd.Flags().StringVarP(&password, "password", "p", "", "The password") + newAuthorCmd.MarkFlagRequired("password") +} + +var newAuthorCmd = &cobra.Command{ + Use: "new-author", + Short: "Creates a new author", + Long: `Creates a new author`, + Run: func(cmd *cobra.Command, args []string) { + db := infra.NewSqliteDB(DbPath) + App(db).AuthorService.Create(user, password) + }, +} diff --git a/cmd/owl/editor_test.go b/cmd/owl/editor_test.go new file mode 100644 index 0000000..df697fe --- /dev/null +++ b/cmd/owl/editor_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "bytes" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "owl-blogs/app" + entrytypes "owl-blogs/entry_types" + "owl-blogs/infra" + "owl-blogs/test" + "path" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func getUserToken(service *app.AuthorService) string { + _, err := service.Create("test", "test") + if err != nil { + panic(err) + } + token, err := service.CreateToken("test") + if err != nil { + panic(err) + } + return token +} + +func TestEditorFormGet(t *testing.T) { + db := test.NewMockDb() + owlApp := App(db) + app := owlApp.FiberApp + token := getUserToken(owlApp.AuthorService) + + req := httptest.NewRequest("GET", "/editor/Image", nil) + req.AddCookie(&http.Cookie{Name: "token", Value: token}) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) +} + +func TestEditorFormGetNoAuth(t *testing.T) { + db := test.NewMockDb() + owlApp := App(db) + app := owlApp.FiberApp + + req := httptest.NewRequest("GET", "/editor/Image", nil) + req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"}) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 302, resp.StatusCode) +} + +func TestEditorFormPost(t *testing.T) { + db := test.NewMockDb() + owlApp := App(db) + app := owlApp.FiberApp + token := getUserToken(owlApp.AuthorService) + repo := infra.NewEntryRepository(db, owlApp.Registry) + binRepo := infra.NewBinaryFileRepo(db) + + fileDir, _ := os.Getwd() + fileName := "../../test/fixtures/test.png" + filePath := path.Join(fileDir, fileName) + + file, err := os.Open(filePath) + require.NoError(t, err) + fileBytes, err := ioutil.ReadFile(filePath) + require.NoError(t, err) + defer file.Close() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("ImageId", filepath.Base(file.Name())) + io.Copy(part, file) + part, _ = writer.CreateFormField("Content") + io.WriteString(part, "test content") + writer.Close() + + req := httptest.NewRequest("POST", "/editor/Image", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.AddCookie(&http.Cookie{Name: "token", Value: token}) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 302, resp.StatusCode) + require.Contains(t, resp.Header.Get("Location"), "/posts/") + + id := strings.Split(resp.Header.Get("Location"), "/")[2] + entry, err := repo.FindById(id) + require.NoError(t, err) + require.Equal(t, "test content", entry.MetaData().(*entrytypes.ImageMetaData).Content) + imageId := entry.MetaData().(*entrytypes.ImageMetaData).ImageId + require.NotZero(t, imageId) + bin, err := binRepo.FindById(imageId) + require.NoError(t, err) + require.Equal(t, bin.Name, "test.png") + require.Equal(t, fileBytes, bin.Data) + +} + +func TestEditorFormPostNoAuth(t *testing.T) { + db := test.NewMockDb() + owlApp := App(db) + app := owlApp.FiberApp + + fileDir, _ := os.Getwd() + fileName := "../../test/fixtures/test.png" + filePath := path.Join(fileDir, fileName) + + file, err := os.Open(filePath) + require.NoError(t, err) + defer file.Close() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("ImageId", filepath.Base(file.Name())) + io.Copy(part, file) + part, _ = writer.CreateFormField("Content") + io.WriteString(part, "test content") + writer.Close() + + req := httptest.NewRequest("POST", "/editor/Image", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"}) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 302, resp.StatusCode) + require.Contains(t, resp.Header.Get("Location"), "/auth/login") + +} diff --git a/cmd/owl/import_v1.go b/cmd/owl/import_v1.go new file mode 100644 index 0000000..d545d12 --- /dev/null +++ b/cmd/owl/import_v1.go @@ -0,0 +1,205 @@ +package main + +import ( + "fmt" + "os" + "owl-blogs/config" + "owl-blogs/domain/model" + entrytypes "owl-blogs/entry_types" + "owl-blogs/importer" + "owl-blogs/infra" + "path" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v2" +) + +var userPath string +var author string + +func init() { + rootCmd.AddCommand(importCmd) + + importCmd.Flags().StringVarP(&userPath, "path", "p", "", "Path to the user folder") + importCmd.MarkFlagRequired("path") + importCmd.Flags().StringVarP(&author, "author", "a", "", "The author name") + importCmd.MarkFlagRequired("author") +} + +var importCmd = &cobra.Command{ + Use: "import", + Short: "Import data from v1", + Long: `Import data from v1`, + Run: func(cmd *cobra.Command, args []string) { + db := infra.NewSqliteDB(DbPath) + app := App(db) + + posts, err := importer.AllUserPosts(userPath) + if err != nil { + panic(err) + } + + // import config + bytes, err := os.ReadFile(path.Join(userPath, "meta/config.yml")) + if err != nil { + panic(err) + } + v1Config := importer.V1UserConfig{} + yaml.Unmarshal(bytes, &v1Config) + + mes := []model.MeLinks{} + for _, me := range v1Config.Me { + mes = append(mes, model.MeLinks{ + Name: me.Name, + Url: me.Url, + }) + } + + lists := []model.EntryList{} + for _, list := range v1Config.Lists { + lists = append(lists, model.EntryList{ + Id: list.Id, + Title: list.Title, + Include: importer.ConvertTypeList(list.Include, app.Registry), + ListType: list.ListType, + }) + } + + headerMenu := []model.MenuItem{} + for _, item := range v1Config.HeaderMenu { + headerMenu = append(headerMenu, model.MenuItem{ + Title: item.Title, + List: item.List, + Url: item.Url, + Post: item.Post, + }) + } + + footerMenu := []model.MenuItem{} + for _, item := range v1Config.FooterMenu { + footerMenu = append(footerMenu, model.MenuItem{ + Title: item.Title, + List: item.List, + Url: item.Url, + Post: item.Post, + }) + } + + v2Config := &model.SiteConfig{} + err = app.SiteConfigRepo.Get(config.SITE_CONFIG, v2Config) + if err != nil { + panic(err) + } + v2Config.Title = v1Config.Title + v2Config.SubTitle = v1Config.SubTitle + v2Config.HeaderColor = v1Config.HeaderColor + v2Config.AuthorName = v1Config.AuthorName + v2Config.Me = mes + v2Config.Lists = lists + v2Config.PrimaryListInclude = importer.ConvertTypeList(v1Config.PrimaryListInclude, app.Registry) + v2Config.HeaderMenu = headerMenu + v2Config.FooterMenu = footerMenu + + err = app.SiteConfigRepo.Update(config.SITE_CONFIG, v2Config) + if err != nil { + panic(err) + } + + for _, post := range posts { + existing, _ := app.EntryService.FindById(post.Id) + if existing != nil { + continue + } + fmt.Println(post.Meta.Type) + + // import assets + mediaDir := path.Join(userPath, post.MediaDir()) + println(mediaDir) + files := importer.ListDir(mediaDir) + for _, file := range files { + // mock entry to pass to binary service + entry := &entrytypes.Article{} + entry.SetID(post.Id) + + fileData, err := os.ReadFile(path.Join(mediaDir, file)) + if err != nil { + panic(err) + } + app.BinaryService.CreateEntryFile(file, fileData, entry) + } + + var entry model.Entry + + switch post.Meta.Type { + case "article": + entry = &entrytypes.Article{} + entry.SetID(post.Id) + entry.SetPublishedAt(&post.Meta.Date) + entry.SetMetaData(&entrytypes.ArticleMetaData{ + Title: post.Meta.Title, + Content: post.Content, + }) + case "bookmark": + entry = &entrytypes.Bookmark{} + entry.SetID(post.Id) + entry.SetPublishedAt(&post.Meta.Date) + entry.SetMetaData(&entrytypes.BookmarkMetaData{ + Url: post.Meta.Bookmark.Url, + Title: post.Meta.Bookmark.Text, + Content: post.Content, + }) + case "reply": + entry = &entrytypes.Reply{} + entry.SetID(post.Id) + entry.SetPublishedAt(&post.Meta.Date) + entry.SetMetaData(&entrytypes.ReplyMetaData{ + Url: post.Meta.Reply.Url, + Title: post.Meta.Reply.Text, + Content: post.Content, + }) + case "photo": + entry = &entrytypes.Image{} + entry.SetID(post.Id) + entry.SetPublishedAt(&post.Meta.Date) + entry.SetMetaData(&entrytypes.ImageMetaData{ + Title: post.Meta.Title, + Content: post.Content, + ImageId: post.Meta.PhotoPath, + }) + case "note": + entry = &entrytypes.Note{} + entry.SetID(post.Id) + entry.SetPublishedAt(&post.Meta.Date) + entry.SetMetaData(&entrytypes.NoteMetaData{ + Content: post.Content, + }) + case "recipe": + entry = &entrytypes.Recipe{} + entry.SetID(post.Id) + entry.SetPublishedAt(&post.Meta.Date) + entry.SetMetaData(&entrytypes.RecipeMetaData{ + Title: post.Meta.Title, + Yield: post.Meta.Recipe.Yield, + Duration: post.Meta.Recipe.Duration, + Ingredients: post.Meta.Recipe.Ingredients, + Content: post.Content, + }) + case "page": + entry = &entrytypes.Page{} + entry.SetID(post.Id) + entry.SetPublishedAt(&post.Meta.Date) + entry.SetMetaData(&entrytypes.PageMetaData{ + Title: post.Meta.Title, + Content: post.Content, + }) + default: + panic("Unknown type") + } + + if entry != nil { + entry.SetAuthorId(author) + app.EntryService.Create(entry) + } + } + }, +} diff --git a/cmd/owl/init.go b/cmd/owl/init.go deleted file mode 100644 index 1eb7ca3..0000000 --- a/cmd/owl/init.go +++ /dev/null @@ -1,38 +0,0 @@ -package main - -import ( - "h4kor/owl-blogs" - - "github.com/spf13/cobra" -) - -var domain string -var singleUser string -var unsafe bool - -func init() { - rootCmd.AddCommand(initCmd) - - initCmd.PersistentFlags().StringVar(&domain, "domain", "http://localhost:8080", "Domain to use") - initCmd.PersistentFlags().StringVar(&singleUser, "single-user", "", "Use single user mode with given username") - initCmd.PersistentFlags().BoolVar(&unsafe, "unsafe", false, "Allow raw html") -} - -var initCmd = &cobra.Command{ - Use: "init", - Short: "Creates a new repository", - Long: `Creates a new repository`, - Run: func(cmd *cobra.Command, args []string) { - _, err := owl.CreateRepository(repoPath, owl.RepoConfig{ - Domain: domain, - SingleUser: singleUser, - AllowRawHtml: unsafe, - }) - if err != nil { - println("Error creating repository: ", err.Error()) - } else { - println("Repository created: ", repoPath) - } - - }, -} diff --git a/cmd/owl/main.go b/cmd/owl/main.go index f9048c1..3f244c3 100644 --- a/cmd/owl/main.go +++ b/cmd/owl/main.go @@ -3,11 +3,16 @@ package main import ( "fmt" "os" + "owl-blogs/app" + entrytypes "owl-blogs/entry_types" + "owl-blogs/infra" + "owl-blogs/web" "github.com/spf13/cobra" ) -var repoPath string +const DbPath = "owlblogs.db" + var rootCmd = &cobra.Command{ Use: "owl", Short: "Owl Blogs is a not so static blog generator", @@ -20,10 +25,26 @@ func Execute() { } } -func init() { +func App(db infra.Database) *web.WebApp { + registry := app.NewEntryTypeRegistry() + registry.Register(&entrytypes.Image{}) + registry.Register(&entrytypes.Article{}) + registry.Register(&entrytypes.Page{}) + registry.Register(&entrytypes.Recipe{}) + registry.Register(&entrytypes.Note{}) + registry.Register(&entrytypes.Bookmark{}) + registry.Register(&entrytypes.Reply{}) - rootCmd.PersistentFlags().StringVar(&repoPath, "repo", ".", "Path to the repository to use.") - rootCmd.PersistentFlags().StringVar(&user, "user", "", "Username. Required for some commands.") + entryRepo := infra.NewEntryRepository(db, registry) + binRepo := infra.NewBinaryFileRepo(db) + authorRepo := infra.NewDefaultAuthorRepo(db) + siteConfigRepo := infra.NewConfigRepo(db) + + entryService := app.NewEntryService(entryRepo) + binaryService := app.NewBinaryFileService(binRepo) + authorService := app.NewAuthorService(authorRepo, siteConfigRepo) + + return web.NewWebApp(entryService, registry, binaryService, authorService, siteConfigRepo) } diff --git a/cmd/owl/media_test.go b/cmd/owl/media_test.go new file mode 100644 index 0000000..abbaaa8 --- /dev/null +++ b/cmd/owl/media_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "net/http/httptest" + "owl-blogs/test" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMediaWithSpace(t *testing.T) { + db := test.NewMockDb() + owlApp := App(db) + app := owlApp.FiberApp + + _, err := owlApp.BinaryService.Create("name with space.jpg", []byte("111")) + require.NoError(t, err) + + req := httptest.NewRequest("GET", "/media/name%20with%20space.jpg", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + +} diff --git a/cmd/owl/new_post.go b/cmd/owl/new_post.go deleted file mode 100644 index df0d3e7..0000000 --- a/cmd/owl/new_post.go +++ /dev/null @@ -1,51 +0,0 @@ -package main - -import ( - "h4kor/owl-blogs" - - "github.com/spf13/cobra" -) - -var postTitle string - -func init() { - rootCmd.AddCommand(newPostCmd) - newPostCmd.PersistentFlags().StringVar(&postTitle, "title", "", "Post title") -} - -var newPostCmd = &cobra.Command{ - Use: "new-post", - Short: "Creates a new post", - Long: `Creates a new post`, - Run: func(cmd *cobra.Command, args []string) { - if user == "" { - println("Username is required") - return - } - - if postTitle == "" { - println("Post title is required") - return - } - - repo, err := owl.OpenRepository(repoPath) - if err != nil { - println("Error opening repository: ", err.Error()) - return - } - - user, err := repo.GetUser(user) - if err != nil { - println("Error getting user: ", err.Error()) - return - } - - post, err := user.CreateNewPost(owl.PostMeta{Type: "article", Title: postTitle, Draft: true}, "") - if err != nil { - println("Error creating post: ", err.Error()) - } else { - println("Post created: ", postTitle) - println("Edit: ", post.ContentFile()) - } - }, -} diff --git a/cmd/owl/new_user.go b/cmd/owl/new_user.go deleted file mode 100644 index d98d8a5..0000000 --- a/cmd/owl/new_user.go +++ /dev/null @@ -1,38 +0,0 @@ -package main - -import ( - "h4kor/owl-blogs" - - "github.com/spf13/cobra" -) - -var user string - -func init() { - rootCmd.AddCommand(newUserCmd) -} - -var newUserCmd = &cobra.Command{ - Use: "new-user", - Short: "Creates a new user", - Long: `Creates a new user`, - Run: func(cmd *cobra.Command, args []string) { - if user == "" { - println("Username is required") - return - } - - repo, err := owl.OpenRepository(repoPath) - if err != nil { - println("Error opening repository: ", err.Error()) - return - } - - _, err = repo.CreateUser(user) - if err != nil { - println("Error creating user: ", err.Error()) - } else { - println("User created: ", user) - } - }, -} diff --git a/cmd/owl/reset_password.go b/cmd/owl/reset_password.go deleted file mode 100644 index 78179cf..0000000 --- a/cmd/owl/reset_password.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -import ( - "fmt" - "h4kor/owl-blogs" - - "github.com/spf13/cobra" -) - -func init() { - rootCmd.AddCommand(resetPasswordCmd) -} - -var resetPasswordCmd = &cobra.Command{ - Use: "reset-password", - Short: "Reset the password for a user", - Long: `Reset the password for a user`, - Run: func(cmd *cobra.Command, args []string) { - if user == "" { - println("Username is required") - return - } - - repo, err := owl.OpenRepository(repoPath) - if err != nil { - println("Error opening repository: ", err.Error()) - return - } - - user, err := repo.GetUser(user) - if err != nil { - println("Error getting user: ", err.Error()) - return - } - - // generate a random password and print it - password := owl.GenerateRandomString(16) - user.ResetPassword(password) - - fmt.Println("User: ", user.Name()) - fmt.Println("New Password: ", password) - - }, -} diff --git a/cmd/owl/web.go b/cmd/owl/web.go index 6a939e4..1eac414 100644 --- a/cmd/owl/web.go +++ b/cmd/owl/web.go @@ -1,17 +1,13 @@ package main import ( - web "h4kor/owl-blogs/cmd/owl/web" + "owl-blogs/infra" "github.com/spf13/cobra" ) -var port int - func init() { rootCmd.AddCommand(webCmd) - - webCmd.PersistentFlags().IntVar(&port, "port", 8080, "Port to use") } var webCmd = &cobra.Command{ @@ -19,6 +15,7 @@ var webCmd = &cobra.Command{ Short: "Start the web server", Long: `Start the web server`, Run: func(cmd *cobra.Command, args []string) { - web.StartServer(repoPath, port) + db := infra.NewSqliteDB(DbPath) + App(db).Run() }, } diff --git a/cmd/owl/web/aliases_test.go b/cmd/owl/web/aliases_test.go deleted file mode 100644 index b941e5a..0000000 --- a/cmd/owl/web/aliases_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package web_test - -import ( - "h4kor/owl-blogs" - main "h4kor/owl-blogs/cmd/owl/web" - "h4kor/owl-blogs/test/assertions" - "net/http" - "net/http/httptest" - "os" - "testing" -) - -func TestRedirectOnAliases(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "") - - content := "---\n" - content += "title: Test\n" - content += "aliases: \n" - content += " - /foo/bar\n" - content += " - /foo/baz\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - // Create Request and Response - req, err := http.NewRequest("GET", "/foo/bar", nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.Router(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusMovedPermanently) - // Check that Location header is set correctly - assertions.AssertEqual(t, rr.Header().Get("Location"), post.UrlPath()) -} - -func TestNoRedirectOnNonExistingAliases(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "") - - content := "---\n" - content += "title: Test\n" - content += "aliases: \n" - content += " - /foo/bar\n" - content += " - /foo/baz\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - // Create Request and Response - req, err := http.NewRequest("GET", "/foo/bar2", nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.Router(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusNotFound) - -} - -func TestNoRedirectIfValidPostUrl(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "") - post2, _ := user.CreateNewPost(owl.PostMeta{Title: "post-2"}, "") - - content := "---\n" - content += "title: Test\n" - content += "aliases: \n" - content += " - " + post2.UrlPath() + "\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - // Create Request and Response - req, err := http.NewRequest("GET", post2.UrlPath(), nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.Router(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) - -} - -func TestRedirectIfInvalidPostUrl(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "") - - content := "---\n" - content += "title: Test\n" - content += "aliases: \n" - content += " - " + user.UrlPath() + "posts/not-a-real-post/" + "\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - // Create Request and Response - req, err := http.NewRequest("GET", user.UrlPath()+"posts/not-a-real-post/", nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.Router(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusMovedPermanently) - -} - -func TestRedirectIfInvalidUserUrl(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "") - - content := "---\n" - content += "title: Test\n" - content += "aliases: \n" - content += " - /user/not-real/ \n" - content += "---\n" - content += "This is a test" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - // Create Request and Response - req, err := http.NewRequest("GET", "/user/not-real/", nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.Router(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusMovedPermanently) - -} - -func TestRedirectIfInvalidMediaUrl(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "") - - content := "---\n" - content += "title: Test\n" - content += "aliases: \n" - content += " - " + post.UrlMediaPath("not-real") + "\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - // Create Request and Response - req, err := http.NewRequest("GET", post.UrlMediaPath("not-real"), nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.Router(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusMovedPermanently) - -} - -func TestDeepAliasInSingleUserMode(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{SingleUser: "test-1"}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "") - - content := "---\n" - content += "title: Create tileable textures with GIMP\n" - content += "author: h4kor\n" - content += "type: post\n" - content += "date: Tue, 13 Sep 2016 16:19:09 +0000\n" - content += "aliases:\n" - content += " - /2016/09/13/create-tileable-textures-with-gimp/\n" - content += "categories:\n" - content += " - GameDev\n" - content += "tags:\n" - content += " - gamedev\n" - content += " - textures\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - // Create Request and Response - req, err := http.NewRequest("GET", "/2016/09/13/create-tileable-textures-with-gimp/", nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusMovedPermanently) - -} diff --git a/cmd/owl/web/auth_handler.go b/cmd/owl/web/auth_handler.go deleted file mode 100644 index 2e08638..0000000 --- a/cmd/owl/web/auth_handler.go +++ /dev/null @@ -1,396 +0,0 @@ -package web - -import ( - "encoding/json" - "fmt" - "h4kor/owl-blogs" - "net/http" - "net/url" - "strings" - - "github.com/julienschmidt/httprouter" -) - -type IndieauthMetaDataResponse struct { - Issuer string `json:"issuer"` - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` - ScopesSupported []string `json:"scopes_supported"` - ResponseTypesSupported []string `json:"response_types_supported"` - GrantTypesSupported []string `json:"grant_types_supported"` -} - -type MeProfileResponse struct { - Name string `json:"name"` - Url string `json:"url"` - Photo string `json:"photo"` -} -type MeResponse struct { - Me string `json:"me"` - Profile MeProfileResponse `json:"profile"` -} - -type AccessTokenResponse struct { - Me string `json:"me"` - TokenType string `json:"token_type"` - AccessToken string `json:"access_token"` - Scope string `json:"scope"` - ExpiresIn int `json:"expires_in"` - RefreshToken string `json:"refresh_token"` -} - -func jsonResponse(w http.ResponseWriter, response interface{}) { - jsonData, err := json.Marshal(response) - if err != nil { - println("Error marshalling json: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Internal server error")) - } - w.Header().Add("Content-Type", "application/json") - w.Write(jsonData) -} - -func userAuthMetadataHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - w.WriteHeader(http.StatusOK) - jsonResponse(w, IndieauthMetaDataResponse{ - Issuer: user.FullUrl(), - AuthorizationEndpoint: user.AuthUrl(), - TokenEndpoint: user.TokenUrl(), - CodeChallengeMethodsSupported: []string{"S256", "plain"}, - ScopesSupported: []string{"profile"}, - ResponseTypesSupported: []string{"code"}, - GrantTypesSupported: []string{"authorization_code"}, - }) - } -} - -func userAuthHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - // get me, cleint_id, redirect_uri, state and response_type from query - me := r.URL.Query().Get("me") - clientId := r.URL.Query().Get("client_id") - redirectUri := r.URL.Query().Get("redirect_uri") - state := r.URL.Query().Get("state") - responseType := r.URL.Query().Get("response_type") - codeChallenge := r.URL.Query().Get("code_challenge") - codeChallengeMethod := r.URL.Query().Get("code_challenge_method") - scope := r.URL.Query().Get("scope") - - // check if request is valid - missing_params := []string{} - if clientId == "" { - missing_params = append(missing_params, "client_id") - } - if redirectUri == "" { - missing_params = append(missing_params, "redirect_uri") - } - if responseType == "" { - missing_params = append(missing_params, "response_type") - } - if len(missing_params) > 0 { - w.WriteHeader(http.StatusBadRequest) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Missing parameters", - Message: "Missing parameters: " + strings.Join(missing_params, ", "), - }) - w.Write([]byte(html)) - return - } - if responseType == "id" { - responseType = "code" - } - if responseType != "code" { - w.WriteHeader(http.StatusBadRequest) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Invalid response_type", - Message: "Must be 'code' ('id' converted to 'code' for legacy support).", - }) - w.Write([]byte(html)) - return - } - if codeChallengeMethod != "" && (codeChallengeMethod != "S256" && codeChallengeMethod != "plain") { - w.WriteHeader(http.StatusBadRequest) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Invalid code_challenge_method", - Message: "Must be 'S256' or 'plain'.", - }) - w.Write([]byte(html)) - return - } - - client_id_url, err := url.Parse(clientId) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Invalid client_id", - Message: "Invalid client_id: " + clientId, - }) - w.Write([]byte(html)) - return - } - redirect_uri_url, err := url.Parse(redirectUri) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Invalid redirect_uri", - Message: "Invalid redirect_uri: " + redirectUri, - }) - w.Write([]byte(html)) - return - } - if client_id_url.Host != redirect_uri_url.Host || client_id_url.Scheme != redirect_uri_url.Scheme { - // check if redirect_uri is registered - resp, _ := repo.HttpClient.Get(clientId) - registered_redirects, _ := repo.Parser.GetRedirctUris(resp) - is_registered := false - for _, registered_redirect := range registered_redirects { - if registered_redirect == redirectUri { - // redirect_uri is registered - is_registered = true - break - } - } - if !is_registered { - w.WriteHeader(http.StatusBadRequest) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Invalid redirect_uri", - Message: redirectUri + " is not registered for " + clientId, - }) - w.Write([]byte(html)) - return - } - } - - // Double Submit Cookie Pattern - // https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie - csrfToken := owl.GenerateRandomString(32) - cookie := http.Cookie{ - Name: "csrf_token", - Value: csrfToken, - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - } - http.SetCookie(w, &cookie) - - reqData := owl.AuthRequestData{ - Me: me, - ClientId: clientId, - RedirectUri: redirectUri, - State: state, - Scope: scope, - ResponseType: responseType, - CodeChallenge: codeChallenge, - CodeChallengeMethod: codeChallengeMethod, - User: user, - CsrfToken: csrfToken, - } - - html, err := owl.RenderUserAuthPage(reqData) - if err != nil { - println("Error rendering auth page: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Internal Server Error", - Message: "Internal Server Error", - }) - w.Write([]byte(html)) - return - } - w.Write([]byte(html)) - } -} - -func verifyAuthCodeRequest(user owl.User, w http.ResponseWriter, r *http.Request) (bool, owl.AuthCode) { - // get form data from post request - err := r.ParseForm() - if err != nil { - println("Error parsing form: ", err.Error()) - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Error parsing form")) - return false, owl.AuthCode{} - } - code := r.Form.Get("code") - client_id := r.Form.Get("client_id") - redirect_uri := r.Form.Get("redirect_uri") - code_verifier := r.Form.Get("code_verifier") - - // check if request is valid - valid, authCode := user.VerifyAuthCode(code, client_id, redirect_uri, code_verifier) - if !valid { - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("Invalid code")) - } - return valid, authCode -} - -func userAuthProfileHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - - valid, _ := verifyAuthCodeRequest(user, w, r) - if valid { - w.WriteHeader(http.StatusOK) - jsonResponse(w, MeResponse{ - Me: user.FullUrl(), - Profile: MeProfileResponse{ - Name: user.Name(), - Url: user.FullUrl(), - Photo: user.AvatarUrl(), - }, - }) - return - } - } -} - -func userAuthTokenHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - - valid, authCode := verifyAuthCodeRequest(user, w, r) - if valid { - if authCode.Scope == "" { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Empty scope, no token issued")) - return - } - - accessToken, duration, err := user.GenerateAccessToken(authCode) - if err != nil { - println("Error generating access token: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Internal server error")) - return - } - jsonResponse(w, AccessTokenResponse{ - Me: user.FullUrl(), - TokenType: "Bearer", - AccessToken: accessToken, - Scope: authCode.Scope, - ExpiresIn: duration, - }) - return - } - } -} - -func userAuthVerifyHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - - // get form data from post request - err = r.ParseForm() - if err != nil { - println("Error parsing form: ", err.Error()) - w.WriteHeader(http.StatusBadRequest) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Error parsing form", - Message: "Error parsing form", - }) - w.Write([]byte(html)) - return - } - password := r.FormValue("password") - client_id := r.FormValue("client_id") - redirect_uri := r.FormValue("redirect_uri") - response_type := r.FormValue("response_type") - state := r.FormValue("state") - code_challenge := r.FormValue("code_challenge") - code_challenge_method := r.FormValue("code_challenge_method") - scope := r.FormValue("scope") - - // CSRF check - formCsrfToken := r.FormValue("csrf_token") - cookieCsrfToken, err := r.Cookie("csrf_token") - - if err != nil { - println("Error getting csrf token from cookie: ", err.Error()) - w.WriteHeader(http.StatusBadRequest) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "CSRF Error", - Message: "Error getting csrf token from cookie", - }) - w.Write([]byte(html)) - return - } - if formCsrfToken != cookieCsrfToken.Value { - println("Invalid csrf token") - w.WriteHeader(http.StatusBadRequest) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "CSRF Error", - Message: "Invalid csrf token", - }) - w.Write([]byte(html)) - return - } - - password_valid := user.VerifyPassword(password) - if !password_valid { - redirect := fmt.Sprintf( - "%s?error=invalid_password&client_id=%s&redirect_uri=%s&response_type=%s&state=%s", - user.AuthUrl(), client_id, redirect_uri, response_type, state, - ) - if code_challenge != "" { - redirect += fmt.Sprintf("&code_challenge=%s&code_challenge_method=%s", code_challenge, code_challenge_method) - } - http.Redirect(w, r, - redirect, - http.StatusFound, - ) - return - } else { - // password is valid, generate code - code, err := user.GenerateAuthCode( - client_id, redirect_uri, code_challenge, code_challenge_method, scope) - if err != nil { - println("Error generating code: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Internal Server Error", - Message: "Error generating code", - }) - w.Write([]byte(html)) - return - } - http.Redirect(w, r, - fmt.Sprintf( - "%s?code=%s&state=%s&iss=%s", - redirect_uri, code, state, - user.FullUrl(), - ), - http.StatusFound, - ) - return - } - - } -} diff --git a/cmd/owl/web/auth_test.go b/cmd/owl/web/auth_test.go deleted file mode 100644 index 78ea651..0000000 --- a/cmd/owl/web/auth_test.go +++ /dev/null @@ -1,428 +0,0 @@ -package web_test - -import ( - "crypto/sha256" - "encoding/base64" - "encoding/json" - main "h4kor/owl-blogs/cmd/owl/web" - "h4kor/owl-blogs/test/assertions" - "h4kor/owl-blogs/test/mocks" - "net/http" - "net/http/httptest" - "net/url" - "strconv" - "strings" - "testing" -) - -func TestAuthPostWrongPassword(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - - csrfToken := "test_csrf_token" - - // Create Request and Response - form := url.Values{} - form.Add("password", "wrongpassword") - form.Add("client_id", "http://example.com") - form.Add("redirect_uri", "http://example.com/response") - form.Add("response_type", "code") - form.Add("state", "test_state") - form.Add("csrf_token", csrfToken) - - req, err := http.NewRequest("POST", user.AuthUrl()+"verify/", strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusFound) - assertions.AssertContains(t, rr.Header().Get("Location"), "error=invalid_password") -} - -func TestAuthPostCorrectPassword(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - - csrfToken := "test_csrf_token" - - // Create Request and Response - form := url.Values{} - form.Add("password", "testpassword") - form.Add("client_id", "http://example.com") - form.Add("redirect_uri", "http://example.com/response") - form.Add("response_type", "code") - form.Add("state", "test_state") - form.Add("csrf_token", csrfToken) - req, err := http.NewRequest("POST", user.AuthUrl()+"verify/", strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusFound) - assertions.AssertContains(t, rr.Header().Get("Location"), "code=") - assertions.AssertContains(t, rr.Header().Get("Location"), "state=test_state") - assertions.AssertContains(t, rr.Header().Get("Location"), "iss="+user.FullUrl()) - assertions.AssertContains(t, rr.Header().Get("Location"), "http://example.com/response") -} - -func TestAuthPostWithIncorrectCode(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile") - - // Create Request and Response - form := url.Values{} - form.Add("code", "wrongcode") - form.Add("client_id", "http://example.com") - form.Add("redirect_uri", "http://example.com/response") - form.Add("grant_type", "authorization_code") - req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusUnauthorized) -} - -func TestAuthPostWithCorrectCode(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile") - - // Create Request and Response - form := url.Values{} - form.Add("code", code) - form.Add("client_id", "http://example.com") - form.Add("redirect_uri", "http://example.com/response") - form.Add("grant_type", "authorization_code") - req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.Header.Add("Accept", "application/json") - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) - // parse response as json - type responseType struct { - Me string `json:"me"` - } - var response responseType - json.Unmarshal(rr.Body.Bytes(), &response) - assertions.AssertEqual(t, response.Me, user.FullUrl()) - -} - -func TestAuthPostWithCorrectCodeAndPKCE(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - - // Create Request and Response - code_verifier := "test_code_verifier" - // create code challenge - h := sha256.New() - h.Write([]byte(code_verifier)) - code_challenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) - code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "S256", "profile") - - form := url.Values{} - form.Add("code", code) - form.Add("client_id", "http://example.com") - form.Add("redirect_uri", "http://example.com/response") - form.Add("grant_type", "authorization_code") - form.Add("code_verifier", code_verifier) - req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.Header.Add("Accept", "application/json") - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) - // parse response as json - type responseType struct { - Me string `json:"me"` - } - var response responseType - json.Unmarshal(rr.Body.Bytes(), &response) - assertions.AssertEqual(t, response.Me, user.FullUrl()) - -} - -func TestAuthPostWithCorrectCodeAndWrongPKCE(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - - // Create Request and Response - code_verifier := "test_code_verifier" - // create code challenge - h := sha256.New() - h.Write([]byte(code_verifier + "wrong")) - code_challenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) - code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "S256", "profile") - - form := url.Values{} - form.Add("code", code) - form.Add("client_id", "http://example.com") - form.Add("redirect_uri", "http://example.com/response") - form.Add("grant_type", "authorization_code") - form.Add("code_verifier", code_verifier) - req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.Header.Add("Accept", "application/json") - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusUnauthorized) -} - -func TestAuthPostWithCorrectCodePKCEPlain(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - - // Create Request and Response - code_verifier := "test_code_verifier" - code_challenge := code_verifier - code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "plain", "profile") - - form := url.Values{} - form.Add("code", code) - form.Add("client_id", "http://example.com") - form.Add("redirect_uri", "http://example.com/response") - form.Add("grant_type", "authorization_code") - form.Add("code_verifier", code_verifier) - req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.Header.Add("Accept", "application/json") - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) -} - -func TestAuthPostWithCorrectCodePKCEPlainWrong(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - - // Create Request and Response - code_verifier := "test_code_verifier" - code_challenge := code_verifier + "wrong" - code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "plain", "profile") - - form := url.Values{} - form.Add("code", code) - form.Add("client_id", "http://example.com") - form.Add("redirect_uri", "http://example.com/response") - form.Add("grant_type", "authorization_code") - form.Add("code_verifier", code_verifier) - req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.Header.Add("Accept", "application/json") - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusUnauthorized) -} - -func TestAuthRedirectUriNotSet(t *testing.T) { - repo, user := getSingleUserTestRepo() - repo.HttpClient = &mocks.MockHttpClient{} - repo.Parser = &mocks.MockParseLinksHtmlParser{ - Links: []string{"http://example2.com/response"}, - } - user.ResetPassword("testpassword") - - csrfToken := "test_csrf_token" - - // Create Request and Response - form := url.Values{} - form.Add("password", "wrongpassword") - form.Add("client_id", "http://example.com") - form.Add("redirect_uri", "http://example2.com/response_not_set") - form.Add("response_type", "code") - form.Add("state", "test_state") - form.Add("csrf_token", csrfToken) - - req, err := http.NewRequest("GET", user.AuthUrl()+"?"+form.Encode(), nil) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusBadRequest) -} - -func TestAuthRedirectUriSet(t *testing.T) { - repo, user := getSingleUserTestRepo() - repo.HttpClient = &mocks.MockHttpClient{} - repo.Parser = &mocks.MockParseLinksHtmlParser{ - Links: []string{"http://example.com/response"}, - } - user.ResetPassword("testpassword") - - csrfToken := "test_csrf_token" - - // Create Request and Response - form := url.Values{} - form.Add("password", "wrongpassword") - form.Add("client_id", "http://example.com") - form.Add("redirect_uri", "http://example.com/response") - form.Add("response_type", "code") - form.Add("state", "test_state") - form.Add("csrf_token", csrfToken) - - req, err := http.NewRequest("GET", user.AuthUrl()+"?"+form.Encode(), nil) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) -} - -func TestAuthRedirectUriSameHost(t *testing.T) { - repo, user := getSingleUserTestRepo() - repo.HttpClient = &mocks.MockHttpClient{} - repo.Parser = &mocks.MockParseLinksHtmlParser{ - Links: []string{}, - } - user.ResetPassword("testpassword") - - csrfToken := "test_csrf_token" - - // Create Request and Response - form := url.Values{} - form.Add("password", "wrongpassword") - form.Add("client_id", "http://example.com") - form.Add("redirect_uri", "http://example.com/response") - form.Add("response_type", "code") - form.Add("state", "test_state") - form.Add("csrf_token", csrfToken) - - req, err := http.NewRequest("GET", user.AuthUrl()+"?"+form.Encode(), nil) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) -} - -func TestAccessTokenCorrectPassword(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile create") - - // Create Request and Response - form := url.Values{} - form.Add("code", code) - form.Add("client_id", "http://example.com") - form.Add("redirect_uri", "http://example.com/response") - form.Add("grant_type", "authorization_code") - req, err := http.NewRequest("POST", user.AuthUrl()+"token/", strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) - // parse response as json - type responseType struct { - Me string `json:"me"` - TokenType string `json:"token_type"` - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - RefreshToken string `json:"refresh_token"` - Scope string `json:"scope"` - } - var response responseType - json.Unmarshal(rr.Body.Bytes(), &response) - assertions.AssertEqual(t, response.Me, user.FullUrl()) - assertions.AssertEqual(t, response.TokenType, "Bearer") - assertions.AssertEqual(t, response.Scope, "profile create") - assertions.Assert(t, response.ExpiresIn > 0, "ExpiresIn should be greater than 0") - assertions.Assert(t, len(response.AccessToken) > 0, "AccessToken should be greater than 0") -} - -func TestAccessTokenWithIncorrectCode(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile") - - // Create Request and Response - form := url.Values{} - form.Add("code", "wrongcode") - form.Add("client_id", "http://example.com") - form.Add("redirect_uri", "http://example.com/response") - form.Add("grant_type", "authorization_code") - req, err := http.NewRequest("POST", user.AuthUrl()+"token/", strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusUnauthorized) -} - -func TestIndieauthMetadata(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - req, _ := http.NewRequest("GET", user.IndieauthMetadataUrl(), nil) - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) - // parse response as json - type responseType struct { - Issuer string `json:"issuer"` - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` - ScopesSupported []string `json:"scopes_supported"` - ResponseTypesSupported []string `json:"response_types_supported"` - GrantTypesSupported []string `json:"grant_types_supported"` - } - var response responseType - json.Unmarshal(rr.Body.Bytes(), &response) - assertions.AssertEqual(t, response.Issuer, user.FullUrl()) - assertions.AssertEqual(t, response.AuthorizationEndpoint, user.AuthUrl()) - assertions.AssertEqual(t, response.TokenEndpoint, user.TokenUrl()) -} diff --git a/cmd/owl/web/editor_handler.go b/cmd/owl/web/editor_handler.go deleted file mode 100644 index 8d1e4e2..0000000 --- a/cmd/owl/web/editor_handler.go +++ /dev/null @@ -1,364 +0,0 @@ -package web - -import ( - "fmt" - "h4kor/owl-blogs" - "io" - "mime/multipart" - "net/http" - "os" - "path" - "strings" - "sync" - "time" - - "github.com/julienschmidt/httprouter" -) - -func isUserLoggedIn(user *owl.User, r *http.Request) bool { - sessionCookie, err := r.Cookie("session") - if err != nil { - return false - } - return user.ValidateSession(sessionCookie.Value) -} - -func setCSRFCookie(w http.ResponseWriter) string { - csrfToken := owl.GenerateRandomString(32) - cookie := http.Cookie{ - Name: "csrf_token", - Value: csrfToken, - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - } - http.SetCookie(w, &cookie) - return csrfToken -} - -func checkCSRF(r *http.Request) bool { - // CSRF check - formCsrfToken := r.FormValue("csrf_token") - cookieCsrfToken, err := r.Cookie("csrf_token") - - if err != nil { - println("Error getting csrf token from cookie: ", err.Error()) - return false - } - if formCsrfToken != cookieCsrfToken.Value { - println("Invalid csrf token") - return false - } - return true -} - -func userLoginGetHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - - if isUserLoggedIn(&user, r) { - http.Redirect(w, r, user.EditorUrl(), http.StatusFound) - return - } - csrfToken := setCSRFCookie(w) - - // get error from query - error_type := r.URL.Query().Get("error") - - html, err := owl.RenderLoginPage(user, error_type, csrfToken) - if err != nil { - println("Error rendering login page: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Internal server error", - Message: "Internal server error", - }) - w.Write([]byte(html)) - return - } - w.Write([]byte(html)) - } -} - -func userLoginPostHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - err = r.ParseForm() - if err != nil { - println("Error parsing form: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Internal server error", - Message: "Internal server error", - }) - w.Write([]byte(html)) - return - } - - // CSRF check - if !checkCSRF(r) { - w.WriteHeader(http.StatusBadRequest) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "CSRF Error", - Message: "Invalid csrf token", - }) - w.Write([]byte(html)) - return - } - - password := r.Form.Get("password") - if password == "" || !user.VerifyPassword(password) { - http.Redirect(w, r, user.EditorLoginUrl()+"?error=wrong_password", http.StatusFound) - return - } - - // set session cookie - cookie := http.Cookie{ - Name: "session", - Value: user.CreateNewSession(), - Path: "/", - Expires: time.Now().Add(30 * 24 * time.Hour), - HttpOnly: true, - } - http.SetCookie(w, &cookie) - http.Redirect(w, r, user.EditorUrl(), http.StatusFound) - } -} - -func userEditorGetHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - - if !isUserLoggedIn(&user, r) { - http.Redirect(w, r, user.EditorLoginUrl(), http.StatusFound) - return - } - - csrfToken := setCSRFCookie(w) - html, err := owl.RenderEditorPage(user, csrfToken) - if err != nil { - println("Error rendering editor page: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Internal server error", - Message: "Internal server error", - }) - w.Write([]byte(html)) - return - } - w.Write([]byte(html)) - } -} - -func userEditorPostHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - - if !isUserLoggedIn(&user, r) { - http.Redirect(w, r, user.EditorLoginUrl(), http.StatusFound) - return - } - - if strings.Split(r.Header.Get("Content-Type"), ";")[0] == "multipart/form-data" { - err = r.ParseMultipartForm(32 << 20) - } else { - err = r.ParseForm() - } - - if err != nil { - println("Error parsing form: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Internal server error", - Message: "Internal server error", - }) - w.Write([]byte(html)) - return - } - - // CSRF check - if !checkCSRF(r) { - w.WriteHeader(http.StatusBadRequest) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "CSRF Error", - Message: "Invalid csrf token", - }) - w.Write([]byte(html)) - return - } - - // get form values - post_type := r.Form.Get("type") - title := r.Form.Get("title") - description := r.Form.Get("description") - content := strings.ReplaceAll(r.Form.Get("content"), "\r", "") - draft := r.Form.Get("draft") - - // recipe values - recipe_yield := r.Form.Get("yield") - recipe_ingredients := strings.ReplaceAll(r.Form.Get("ingredients"), "\r", "") - recipe_duration := r.Form.Get("duration") - - // conditional values - reply_url := r.Form.Get("reply_url") - bookmark_url := r.Form.Get("bookmark_url") - - // photo values - var photo_file multipart.File - var photo_header *multipart.FileHeader - if strings.Split(r.Header.Get("Content-Type"), ";")[0] == "multipart/form-data" { - photo_file, photo_header, err = r.FormFile("photo") - if err != nil && err != http.ErrMissingFile { - println("Error getting photo file: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Internal server error", - Message: "Internal server error", - }) - w.Write([]byte(html)) - return - } - } - - // validate form values - if post_type == "" { - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Missing post type", - Message: "Post type is required", - }) - w.Write([]byte(html)) - return - } - if (post_type == "article" || post_type == "page" || post_type == "recipe") && title == "" { - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Missing Title", - Message: "Articles and Pages must have a title", - }) - w.Write([]byte(html)) - return - } - if post_type == "reply" && reply_url == "" { - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Missing URL", - Message: "You must provide a URL to reply to", - }) - w.Write([]byte(html)) - return - } - if post_type == "bookmark" && bookmark_url == "" { - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Missing URL", - Message: "You must provide a URL to bookmark", - }) - w.Write([]byte(html)) - return - } - if post_type == "photo" && photo_file == nil { - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Missing Photo", - Message: "You must provide a photo to upload", - }) - w.Write([]byte(html)) - return - } - - // TODO: scrape reply_url for title and description - // TODO: scrape bookmark_url for title and description - - // create post - meta := owl.PostMeta{ - Type: post_type, - Title: title, - Description: description, - Draft: draft == "on", - Date: time.Now(), - Reply: owl.ReplyData{ - Url: reply_url, - }, - Bookmark: owl.BookmarkData{ - Url: bookmark_url, - }, - Recipe: owl.RecipeData{ - Yield: recipe_yield, - Ingredients: strings.Split(recipe_ingredients, "\n"), - Duration: recipe_duration, - }, - } - - if photo_file != nil { - meta.PhotoPath = photo_header.Filename - } - - post, err := user.CreateNewPost(meta, content) - - // save photo - if photo_file != nil { - println("Saving photo: ", photo_header.Filename) - photo_path := path.Join(post.MediaDir(), photo_header.Filename) - media_file, err := os.Create(photo_path) - if err != nil { - println("Error creating photo file: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Internal server error", - Message: "Internal server error", - }) - w.Write([]byte(html)) - return - } - defer media_file.Close() - io.Copy(media_file, photo_file) - } - - if err != nil { - println("Error creating post: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Internal server error", - Message: "Internal server error", - }) - w.Write([]byte(html)) - return - } - - // redirect to post - if !post.Meta().Draft { - // scan for webmentions - post.ScanForLinks() - webmentions := post.OutgoingWebmentions() - println("Found ", len(webmentions), " links") - wg := sync.WaitGroup{} - wg.Add(len(webmentions)) - for _, mention := range post.OutgoingWebmentions() { - go func(mention owl.WebmentionOut) { - fmt.Printf("Sending webmention to %s", mention.Target) - defer wg.Done() - post.SendWebmention(mention) - }(mention) - } - wg.Wait() - http.Redirect(w, r, post.FullUrl(), http.StatusFound) - } else { - http.Redirect(w, r, user.EditorUrl(), http.StatusFound) - } - } -} diff --git a/cmd/owl/web/editor_test.go b/cmd/owl/web/editor_test.go deleted file mode 100644 index 46268e6..0000000 --- a/cmd/owl/web/editor_test.go +++ /dev/null @@ -1,346 +0,0 @@ -package web_test - -import ( - "bytes" - "h4kor/owl-blogs" - main "h4kor/owl-blogs/cmd/owl/web" - "h4kor/owl-blogs/test/assertions" - "h4kor/owl-blogs/test/mocks" - "io" - "io/ioutil" - "mime/multipart" - "net/http" - "net/http/httptest" - "net/url" - "path" - "strconv" - "strings" - "testing" -) - -type CountMockHttpClient struct { - InvokedGet int - InvokedPost int - InvokedPostForm int -} - -func (c *CountMockHttpClient) Get(url string) (resp *http.Response, err error) { - c.InvokedGet++ - return &http.Response{}, nil -} - -func (c *CountMockHttpClient) Post(url, contentType string, body io.Reader) (resp *http.Response, err error) { - c.InvokedPost++ - return &http.Response{}, nil -} - -func (c *CountMockHttpClient) PostForm(url string, data url.Values) (resp *http.Response, err error) { - c.InvokedPostForm++ - return &http.Response{}, nil -} - -func TestLoginWrongPassword(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - - csrfToken := "test_csrf_token" - - // Create Request and Response - form := url.Values{} - form.Add("password", "wrongpassword") - form.Add("csrf_token", csrfToken) - - req, err := http.NewRequest("POST", user.EditorLoginUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - // check redirect to login page - - assertions.AssertEqual(t, rr.Header().Get("Location"), user.EditorLoginUrl()+"?error=wrong_password") -} - -func TestLoginCorrectPassword(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - - csrfToken := "test_csrf_token" - - // Create Request and Response - form := url.Values{} - form.Add("password", "testpassword") - form.Add("csrf_token", csrfToken) - - req, err := http.NewRequest("POST", user.EditorLoginUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - // check redirect to login page - assertions.AssertStatus(t, rr, http.StatusFound) - assertions.AssertEqual(t, rr.Header().Get("Location"), user.EditorUrl()) -} - -func TestEditorWithoutSession(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - user.CreateNewSession() - - req, err := http.NewRequest("GET", user.EditorUrl(), nil) - // req.AddCookie(&http.Cookie{Name: "session", Value: sessionId}) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusFound) - assertions.AssertEqual(t, rr.Header().Get("Location"), user.EditorLoginUrl()) - -} - -func TestEditorWithSession(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - sessionId := user.CreateNewSession() - - req, err := http.NewRequest("GET", user.EditorUrl(), nil) - req.AddCookie(&http.Cookie{Name: "session", Value: sessionId}) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) -} - -func TestEditorPostWithoutSession(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - user.CreateNewSession() - - csrfToken := "test_csrf_token" - - // Create Request and Response - form := url.Values{} - form.Add("type", "article") - form.Add("title", "testtitle") - form.Add("content", "testcontent") - form.Add("csrf_token", csrfToken) - - req, err := http.NewRequest("POST", user.EditorUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusFound) - assertions.AssertEqual(t, rr.Header().Get("Location"), user.EditorLoginUrl()) -} - -func TestEditorPostWithSession(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - sessionId := user.CreateNewSession() - - csrfToken := "test_csrf_token" - - // Create Request and Response - form := url.Values{} - form.Add("type", "article") - form.Add("title", "testtitle") - form.Add("content", "testcontent") - form.Add("csrf_token", csrfToken) - - req, err := http.NewRequest("POST", user.EditorUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) - req.AddCookie(&http.Cookie{Name: "session", Value: sessionId}) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - posts, _ := user.AllPosts() - assertions.AssertEqual(t, len(posts), 1) - post := posts[0] - - assertions.AssertStatus(t, rr, http.StatusFound) - assertions.AssertEqual(t, rr.Header().Get("Location"), post.FullUrl()) -} - -func TestEditorPostWithSessionNote(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - sessionId := user.CreateNewSession() - - csrfToken := "test_csrf_token" - - // Create Request and Response - form := url.Values{} - form.Add("type", "note") - form.Add("content", "testcontent") - form.Add("csrf_token", csrfToken) - - req, err := http.NewRequest("POST", user.EditorUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) - req.AddCookie(&http.Cookie{Name: "session", Value: sessionId}) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - posts, _ := user.AllPosts() - assertions.AssertEqual(t, len(posts), 1) - post := posts[0] - - assertions.AssertStatus(t, rr, http.StatusFound) - assertions.AssertEqual(t, rr.Header().Get("Location"), post.FullUrl()) -} - -func TestEditorSendsWebmentions(t *testing.T) { - repo, user := getSingleUserTestRepo() - repo.HttpClient = &CountMockHttpClient{} - repo.Parser = &mocks.MockHtmlParser{} - user.ResetPassword("testpassword") - - mentioned_post, _ := user.CreateNewPost(owl.PostMeta{Title: "test"}, "") - - sessionId := user.CreateNewSession() - - csrfToken := "test_csrf_token" - - // Create Request and Response - form := url.Values{} - form.Add("type", "note") - form.Add("content", "[test]("+mentioned_post.FullUrl()+")") - form.Add("csrf_token", csrfToken) - - req, err := http.NewRequest("POST", user.EditorUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) - req.AddCookie(&http.Cookie{Name: "session", Value: sessionId}) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - posts, _ := user.AllPosts() - assertions.AssertEqual(t, len(posts), 2) - post := posts[0] - assertions.AssertLen(t, post.OutgoingWebmentions(), 1) - assertions.AssertStatus(t, rr, http.StatusFound) - assertions.AssertEqual(t, repo.HttpClient.(*CountMockHttpClient).InvokedPostForm, 1) - -} - -func TestEditorPostWithSessionRecipe(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - sessionId := user.CreateNewSession() - - csrfToken := "test_csrf_token" - - // Create Request and Response - form := url.Values{} - form.Add("type", "recipe") - form.Add("title", "testtitle") - form.Add("yield", "2") - form.Add("duration", "1 hour") - form.Add("ingredients", "water\nwheat") - form.Add("content", "testcontent") - form.Add("csrf_token", csrfToken) - - req, err := http.NewRequest("POST", user.EditorUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) - req.AddCookie(&http.Cookie{Name: "session", Value: sessionId}) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - posts, _ := user.AllPosts() - assertions.AssertEqual(t, len(posts), 1) - post := posts[0] - - assertions.AssertLen(t, post.Meta().Recipe.Ingredients, 2) - - assertions.AssertStatus(t, rr, http.StatusFound) - assertions.AssertEqual(t, rr.Header().Get("Location"), post.FullUrl()) -} - -func TestEditorPostWithSessionPhoto(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - sessionId := user.CreateNewSession() - - csrfToken := "test_csrf_token" - - // read photo from file - photo_data, err := ioutil.ReadFile("../../../fixtures/image.png") - assertions.AssertNoError(t, err, "Error reading photo") - - // Create Request and Response - bodyBuf := &bytes.Buffer{} - bodyWriter := multipart.NewWriter(bodyBuf) - - // write photo - fileWriter, err := bodyWriter.CreateFormFile("photo", "../../../fixtures/image.png") - assertions.AssertNoError(t, err, "Error creating form file") - _, err = fileWriter.Write(photo_data) - assertions.AssertNoError(t, err, "Error writing photo") - - // write other fields - bodyWriter.WriteField("type", "photo") - bodyWriter.WriteField("title", "testtitle") - bodyWriter.WriteField("content", "testcontent") - bodyWriter.WriteField("csrf_token", csrfToken) - - // close body writer - err = bodyWriter.Close() - assertions.AssertNoError(t, err, "Error closing body writer") - - req, err := http.NewRequest("POST", user.EditorUrl(), bodyBuf) - req.Header.Add("Content-Type", "multipart/form-data; boundary="+bodyWriter.Boundary()) - req.Header.Add("Content-Length", strconv.Itoa(len(bodyBuf.Bytes()))) - req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) - req.AddCookie(&http.Cookie{Name: "session", Value: sessionId}) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusFound) - - posts, _ := user.AllPosts() - assertions.AssertEqual(t, len(posts), 1) - post := posts[0] - assertions.AssertEqual(t, rr.Header().Get("Location"), post.FullUrl()) - - assertions.AssertNotEqual(t, post.Meta().PhotoPath, "") - ret_photo_data, err := ioutil.ReadFile(path.Join(post.MediaDir(), post.Meta().PhotoPath)) - assertions.AssertNoError(t, err, "Error reading photo") - assertions.AssertEqual(t, len(photo_data), len(ret_photo_data)) - if len(photo_data) == len(ret_photo_data) { - for i := range photo_data { - assertions.AssertEqual(t, photo_data[i], ret_photo_data[i]) - } - } - -} diff --git a/cmd/owl/web/handler.go b/cmd/owl/web/handler.go deleted file mode 100644 index 37f3791..0000000 --- a/cmd/owl/web/handler.go +++ /dev/null @@ -1,407 +0,0 @@ -package web - -import ( - "fmt" - "h4kor/owl-blogs" - "net/http" - "net/url" - "os" - "path" - "strings" - "time" - - "github.com/julienschmidt/httprouter" -) - -func getUserFromRepo(repo *owl.Repository, ps httprouter.Params) (owl.User, error) { - if config, _ := repo.Config(); config.SingleUser != "" { - return repo.GetUser(config.SingleUser) - } - userName := ps.ByName("user") - user, err := repo.GetUser(userName) - if err != nil { - return owl.User{}, err - } - return user, nil -} - -func repoIndexHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - html, err := owl.RenderUserList(*repo) - - if err != nil { - println("Error rendering index: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Internal server error")) - return - } - println("Rendering index") - w.Write([]byte(html)) - } -} - -func userIndexHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - html, err := owl.RenderIndexPage(user) - if err != nil { - println("Error rendering index page: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Internal server error", - Message: "Internal server error", - }) - w.Write([]byte(html)) - return - } - println("Rendering index page for user", user.Name()) - w.Write([]byte(html)) - } -} - -func postListHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - listId := ps.ByName("list") - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - - list, err := user.GetPostList(listId) - - if err != nil { - println("Error getting post list: ", err.Error()) - notFoundUserHandler(repo, user)(w, r) - return - } - - html, err := owl.RenderPostList(user, list) - if err != nil { - println("Error rendering index page: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Internal server error", - Message: "Internal server error", - }) - w.Write([]byte(html)) - return - } - println("Rendering index page for user", user.Name()) - w.Write([]byte(html)) - } -} - -func userWebmentionHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - user, err := getUserFromRepo(repo, ps) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("User not found")) - return - } - err = r.ParseForm() - if err != nil { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Unable to parse form data")) - return - } - params := r.PostForm - target := params["target"] - source := params["source"] - if len(target) == 0 { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("No target provided")) - return - } - if len(source) == 0 { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("No source provided")) - return - } - - if len(target[0]) < 7 || (target[0][:7] != "http://" && target[0][:8] != "https://") { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Not a valid target")) - return - } - - if len(source[0]) < 7 || (source[0][:7] != "http://" && source[0][:8] != "https://") { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Not a valid source")) - return - } - - if source[0] == target[0] { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("target and source are equal")) - return - } - - tryAlias := func(target string) owl.Post { - parsedTarget, _ := url.Parse(target) - aliases, _ := repo.PostAliases() - fmt.Printf("aliases %v", aliases) - fmt.Printf("parsedTarget %v", parsedTarget) - if _, ok := aliases[parsedTarget.Path]; ok { - return aliases[parsedTarget.Path] - } - return nil - } - - var aliasPost owl.Post - parts := strings.Split(target[0], "/") - if len(parts) < 2 { - aliasPost = tryAlias(target[0]) - if aliasPost == nil { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Not found")) - return - } - } - postId := parts[len(parts)-2] - foundPost, err := user.GetPost(postId) - if err != nil && aliasPost == nil { - aliasPost = tryAlias(target[0]) - if aliasPost == nil { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Post not found")) - return - } - } - if aliasPost != nil { - foundPost = aliasPost - } - err = foundPost.AddIncomingWebmention(source[0]) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Unable to process webmention")) - return - } - - w.WriteHeader(http.StatusAccepted) - w.Write([]byte("")) - } -} - -func userRSSHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - xml, err := owl.RenderRSSFeed(user) - if err != nil { - println("Error rendering index page: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Internal server error", - Message: "Internal server error", - }) - w.Write([]byte(html)) - return - } - println("Rendering index page for user", user.Name()) - w.Header().Set("Content-Type", "application/rss+xml") - w.Write([]byte(xml)) - } -} - -func postHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - postId := ps.ByName("post") - - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - post, err := user.GetPost(postId) - - if err != nil { - println("Error getting post: ", err.Error()) - notFoundUserHandler(repo, user)(w, r) - return - } - - meta := post.Meta() - if meta.Draft { - println("Post is a draft") - notFoundUserHandler(repo, user)(w, r) - return - } - - html, err := owl.RenderPost(post) - if err != nil { - println("Error rendering post: ", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Internal server error", - Message: "Internal server error", - }) - w.Write([]byte(html)) - return - } - println("Rendering post", postId) - w.Write([]byte(html)) - - } -} - -func postMediaHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - postId := ps.ByName("post") - filepath := ps.ByName("filepath") - - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - post, err := user.GetPost(postId) - if err != nil { - println("Error getting post: ", err.Error()) - notFoundUserHandler(repo, user)(w, r) - return - } - filepath = path.Join(post.MediaDir(), filepath) - if _, err := os.Stat(filepath); err != nil { - println("Error getting file: ", err.Error()) - notFoundUserHandler(repo, user)(w, r) - return - } - http.ServeFile(w, r, filepath) - } -} - -func userMicropubHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - - // parse request form - err = r.ParseForm() - if err != nil { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Bad request")) - return - } - - // verify access token - token := r.Header.Get("Authorization") - if token == "" { - token = r.Form.Get("access_token") - } else { - token = strings.TrimPrefix(token, "Bearer ") - } - - valid, _ := user.ValidateAccessToken(token) - if !valid { - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("Unauthorized")) - return - } - - h := r.Form.Get("h") - content := r.Form.Get("content") - name := r.Form.Get("name") - inReplyTo := r.Form.Get("in-reply-to") - - if h != "entry" { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Bad request. h must be entry. Got " + h)) - return - } - if content == "" { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Bad request. content is required")) - return - } - - // create post - post, err := user.CreateNewPost( - owl.PostMeta{ - Title: name, - Reply: owl.ReplyData{ - Url: inReplyTo, - }, - Date: time.Now(), - }, - content, - ) - - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Internal server error")) - return - } - - w.Header().Add("Location", post.FullUrl()) - w.WriteHeader(http.StatusCreated) - - } -} - -func userMediaHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { - return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - filepath := ps.ByName("filepath") - - user, err := getUserFromRepo(repo, ps) - if err != nil { - println("Error getting user: ", err.Error()) - notFoundHandler(repo)(w, r) - return - } - filepath = path.Join(user.MediaDir(), filepath) - if _, err := os.Stat(filepath); err != nil { - println("Error getting file: ", err.Error()) - notFoundUserHandler(repo, user)(w, r) - return - } - http.ServeFile(w, r, filepath) - } -} - -func notFoundHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - aliases, _ := repo.PostAliases() - if _, ok := aliases[path]; ok { - http.Redirect(w, r, aliases[path].UrlPath(), http.StatusMovedPermanently) - return - } - w.WriteHeader(http.StatusNotFound) - w.Write([]byte("Not found")) - } -} - -func notFoundUserHandler(repo *owl.Repository, user owl.User) func(http.ResponseWriter, *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - aliases, _ := repo.PostAliases() - if _, ok := aliases[path]; ok { - http.Redirect(w, r, aliases[path].UrlPath(), http.StatusMovedPermanently) - return - } - w.WriteHeader(http.StatusNotFound) - html, _ := owl.RenderUserError(user, owl.ErrorMessage{ - Error: "Not found", - Message: "The page you requested could not be found", - }) - w.Write([]byte(html)) - } -} diff --git a/cmd/owl/web/micropub_test.go b/cmd/owl/web/micropub_test.go deleted file mode 100644 index d9574ff..0000000 --- a/cmd/owl/web/micropub_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package web_test - -import ( - "h4kor/owl-blogs" - main "h4kor/owl-blogs/cmd/owl/web" - "h4kor/owl-blogs/test/assertions" - "net/http" - "net/http/httptest" - "net/url" - "strconv" - "strings" - "testing" -) - -func TestMicropubMinimalArticle(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - - code, _ := user.GenerateAuthCode( - "test", "test", "test", "test", "test", - ) - token, _, _ := user.GenerateAccessToken(owl.AuthCode{ - Code: code, - ClientId: "test", - RedirectUri: "test", - CodeChallenge: "test", - CodeChallengeMethod: "test", - Scope: "test", - }) - - // Create Request and Response - form := url.Values{} - form.Add("h", "entry") - form.Add("name", "Test Article") - form.Add("content", "Test Content") - - req, err := http.NewRequest("POST", user.MicropubUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.Header.Add("Authorization", "Bearer "+token) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusCreated) -} - -func TestMicropubWithoutName(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - - code, _ := user.GenerateAuthCode( - "test", "test", "test", "test", "test", - ) - token, _, _ := user.GenerateAccessToken(owl.AuthCode{ - Code: code, - ClientId: "test", - RedirectUri: "test", - CodeChallenge: "test", - CodeChallengeMethod: "test", - Scope: "test", - }) - - // Create Request and Response - form := url.Values{} - form.Add("h", "entry") - form.Add("content", "Test Content") - - req, err := http.NewRequest("POST", user.MicropubUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - req.Header.Add("Authorization", "Bearer "+token) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusCreated) - loc_header := rr.Header().Get("Location") - assertions.Assert(t, loc_header != "", "Location header should be set") -} - -func TestMicropubAccessTokenInBody(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - - code, _ := user.GenerateAuthCode( - "test", "test", "test", "test", "test", - ) - token, _, _ := user.GenerateAccessToken(owl.AuthCode{ - Code: code, - ClientId: "test", - RedirectUri: "test", - CodeChallenge: "test", - CodeChallengeMethod: "test", - Scope: "test", - }) - - // Create Request and Response - form := url.Values{} - form.Add("h", "entry") - form.Add("content", "Test Content") - form.Add("access_token", token) - - req, err := http.NewRequest("POST", user.MicropubUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusCreated) - loc_header := rr.Header().Get("Location") - assertions.Assert(t, loc_header != "", "Location header should be set") -} - -// func TestMicropubAccessTokenInBoth(t *testing.T) { -// repo, user := getSingleUserTestRepo() -// user.ResetPassword("testpassword") - -// code, _ := user.GenerateAuthCode( -// "test", "test", "test", "test", "test", -// ) -// token, _, _ := user.GenerateAccessToken(owl.AuthCode{ -// Code: code, -// ClientId: "test", -// RedirectUri: "test", -// CodeChallenge: "test", -// CodeChallengeMethod: "test", -// Scope: "test", -// }) - -// // Create Request and Response -// form := url.Values{} -// form.Add("h", "entry") -// form.Add("content", "Test Content") -// form.Add("access_token", token) - -// req, err := http.NewRequest("POST", user.MicropubUrl(), strings.NewReader(form.Encode())) -// req.Header.Add("Content-Type", "application/x-www-form-urlencoded") -// req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) -// req.Header.Add("Authorization", "Bearer "+token) -// assertions.AssertNoError(t, err, "Error creating request") -// rr := httptest.NewRecorder() -// router := main.SingleUserRouter(&repo) -// router.ServeHTTP(rr, req) - -// assertions.AssertStatus(t, rr, http.StatusBadRequest) -// } - -func TestMicropubNoAccessToken(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.ResetPassword("testpassword") - - code, _ := user.GenerateAuthCode( - "test", "test", "test", "test", "test", - ) - user.GenerateAccessToken(owl.AuthCode{ - Code: code, - ClientId: "test", - RedirectUri: "test", - CodeChallenge: "test", - CodeChallengeMethod: "test", - Scope: "test", - }) - - // Create Request and Response - form := url.Values{} - form.Add("h", "entry") - form.Add("content", "Test Content") - - req, err := http.NewRequest("POST", user.MicropubUrl(), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode()))) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusUnauthorized) -} diff --git a/cmd/owl/web/multi_user_test.go b/cmd/owl/web/multi_user_test.go deleted file mode 100644 index 74acc10..0000000 --- a/cmd/owl/web/multi_user_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package web_test - -import ( - "h4kor/owl-blogs" - main "h4kor/owl-blogs/cmd/owl/web" - "h4kor/owl-blogs/test/assertions" - "math/rand" - "net/http" - "net/http/httptest" - "os" - "path" - "testing" - "time" -) - -func randomName() string { - rand.Seed(time.Now().UnixNano()) - var letters = []rune("abcdefghijklmnopqrstuvwxyz") - b := make([]rune, 8) - for i := range b { - b[i] = letters[rand.Intn(len(letters))] - } - return string(b) -} - -func testRepoName() string { - return "/tmp/" + randomName() -} - -func getTestRepo(config owl.RepoConfig) owl.Repository { - repo, _ := owl.CreateRepository(testRepoName(), config) - return repo -} - -func TestMultiUserRepoIndexHandler(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - repo.CreateUser("user_1") - repo.CreateUser("user_2") - - // Create Request and Response - req, err := http.NewRequest("GET", "/", nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.Router(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) - - // Check the response body contains names of users - assertions.AssertContains(t, rr.Body.String(), "user_1") - assertions.AssertContains(t, rr.Body.String(), "user_2") -} - -func TestMultiUserUserIndexHandler(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "") - - // Create Request and Response - req, err := http.NewRequest("GET", user.UrlPath(), nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.Router(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) - - // Check the response body contains names of users - assertions.AssertContains(t, rr.Body.String(), "post-1") -} - -func TestMultiUserPostHandler(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "") - - // Create Request and Response - req, err := http.NewRequest("GET", post.UrlPath(), nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.Router(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) -} - -func TestMultiUserPostMediaHandler(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "") - - // Create test media file - path := path.Join(post.MediaDir(), "data.txt") - err := os.WriteFile(path, []byte("test"), 0644) - assertions.AssertNoError(t, err, "Error creating request") - - // Create Request and Response - req, err := http.NewRequest("GET", post.UrlMediaPath("data.txt"), nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.Router(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) - - // Check the response body contains data of media file - assertions.Assert(t, rr.Body.String() == "test", "Response body is not equal to test") -} diff --git a/cmd/owl/web/post_test.go b/cmd/owl/web/post_test.go deleted file mode 100644 index 11c9dc9..0000000 --- a/cmd/owl/web/post_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package web_test - -import ( - "h4kor/owl-blogs" - main "h4kor/owl-blogs/cmd/owl/web" - "h4kor/owl-blogs/test/assertions" - "net/http" - "net/http/httptest" - "os" - "testing" -) - -func TestPostHandlerReturns404OnDrafts(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "") - - content := "---\n" - content += "title: test\n" - content += "draft: true\n" - content += "---\n" - content += "\n" - content += "Write your post here.\n" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - // Create Request and Response - req, err := http.NewRequest("GET", post.UrlPath(), nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.Router(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusNotFound) -} diff --git a/cmd/owl/web/rss_test.go b/cmd/owl/web/rss_test.go deleted file mode 100644 index 6ac5f83..0000000 --- a/cmd/owl/web/rss_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package web_test - -import ( - "h4kor/owl-blogs" - main "h4kor/owl-blogs/cmd/owl/web" - "h4kor/owl-blogs/test/assertions" - "net/http" - "net/http/httptest" - "testing" -) - -func TestMultiUserUserRssIndexHandler(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "") - - // Create Request and Response - req, err := http.NewRequest("GET", user.UrlPath()+"index.xml", nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.Router(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) - - // Check the response Content-Type is what we expect. - assertions.AssertContains(t, rr.Header().Get("Content-Type"), "application/rss+xml") - - // Check the response body contains names of users - assertions.AssertContains(t, rr.Body.String(), "post-1") -} diff --git a/cmd/owl/web/server.go b/cmd/owl/web/server.go deleted file mode 100644 index b36342e..0000000 --- a/cmd/owl/web/server.go +++ /dev/null @@ -1,95 +0,0 @@ -package web - -import ( - "h4kor/owl-blogs" - "net/http" - "os" - "strconv" - - "github.com/julienschmidt/httprouter" -) - -func Router(repo *owl.Repository) http.Handler { - router := httprouter.New() - router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir())) - router.GET("/", repoIndexHandler(repo)) - router.GET("/user/:user/", userIndexHandler(repo)) - router.GET("/user/:user/lists/:list/", postListHandler(repo)) - // Editor - router.GET("/user/:user/editor/auth/", userLoginGetHandler(repo)) - router.POST("/user/:user/editor/auth/", userLoginPostHandler(repo)) - router.GET("/user/:user/editor/", userEditorGetHandler(repo)) - router.POST("/user/:user/editor/", userEditorPostHandler(repo)) - // Media - router.GET("/user/:user/media/*filepath", userMediaHandler(repo)) - // RSS - router.GET("/user/:user/index.xml", userRSSHandler(repo)) - // Posts - router.GET("/user/:user/posts/:post/", postHandler(repo)) - router.GET("/user/:user/posts/:post/media/*filepath", postMediaHandler(repo)) - // Webmention - router.POST("/user/:user/webmention/", userWebmentionHandler(repo)) - // Micropub - router.POST("/user/:user/micropub/", userMicropubHandler(repo)) - // IndieAuth - router.GET("/user/:user/auth/", userAuthHandler(repo)) - router.POST("/user/:user/auth/", userAuthProfileHandler(repo)) - router.POST("/user/:user/auth/verify/", userAuthVerifyHandler(repo)) - router.POST("/user/:user/auth/token/", userAuthTokenHandler(repo)) - router.GET("/user/:user/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo)) - router.NotFound = http.HandlerFunc(notFoundHandler(repo)) - return router -} - -func SingleUserRouter(repo *owl.Repository) http.Handler { - router := httprouter.New() - router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir())) - router.GET("/", userIndexHandler(repo)) - router.GET("/lists/:list/", postListHandler(repo)) - // Editor - router.GET("/editor/auth/", userLoginGetHandler(repo)) - router.POST("/editor/auth/", userLoginPostHandler(repo)) - router.GET("/editor/", userEditorGetHandler(repo)) - router.POST("/editor/", userEditorPostHandler(repo)) - // Media - router.GET("/media/*filepath", userMediaHandler(repo)) - // RSS - router.GET("/index.xml", userRSSHandler(repo)) - // Posts - router.GET("/posts/:post/", postHandler(repo)) - router.GET("/posts/:post/media/*filepath", postMediaHandler(repo)) - // Webmention - router.POST("/webmention/", userWebmentionHandler(repo)) - // Micropub - router.POST("/micropub/", userMicropubHandler(repo)) - // IndieAuth - router.GET("/auth/", userAuthHandler(repo)) - router.POST("/auth/", userAuthProfileHandler(repo)) - router.POST("/auth/verify/", userAuthVerifyHandler(repo)) - router.POST("/auth/token/", userAuthTokenHandler(repo)) - router.GET("/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo)) - router.NotFound = http.HandlerFunc(notFoundHandler(repo)) - return router -} - -func StartServer(repoPath string, port int) { - var repo owl.Repository - var err error - repo, err = owl.OpenRepository(repoPath) - - if err != nil { - println("Error opening repository: ", err.Error()) - os.Exit(1) - } - - var router http.Handler - if config, _ := repo.Config(); config.SingleUser != "" { - router = SingleUserRouter(&repo) - } else { - router = Router(&repo) - } - - println("Listening on port", port) - http.ListenAndServe(":"+strconv.Itoa(port), router) - -} diff --git a/cmd/owl/web/single_user_test.go b/cmd/owl/web/single_user_test.go deleted file mode 100644 index 1ab409d..0000000 --- a/cmd/owl/web/single_user_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package web_test - -import ( - owl "h4kor/owl-blogs" - main "h4kor/owl-blogs/cmd/owl/web" - "h4kor/owl-blogs/test/assertions" - "net/http" - "net/http/httptest" - "os" - "path" - "testing" -) - -func getSingleUserTestRepo() (owl.Repository, owl.User) { - repo, _ := owl.CreateRepository(testRepoName(), owl.RepoConfig{SingleUser: "test-1"}) - user, _ := repo.CreateUser("test-1") - return repo, user -} - -func TestSingleUserUserIndexHandler(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "") - - // Create Request and Response - req, err := http.NewRequest("GET", user.UrlPath(), nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) - - // Check the response body contains names of users - assertions.AssertContains(t, rr.Body.String(), "post-1") -} - -func TestSingleUserPostHandler(t *testing.T) { - repo, user := getSingleUserTestRepo() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "") - - // Create Request and Response - req, err := http.NewRequest("GET", post.UrlPath(), nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) -} - -func TestSingleUserPostMediaHandler(t *testing.T) { - repo, user := getSingleUserTestRepo() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "") - - // Create test media file - path := path.Join(post.MediaDir(), "data.txt") - err := os.WriteFile(path, []byte("test"), 0644) - assertions.AssertNoError(t, err, "Error creating request") - - // Create Request and Response - req, err := http.NewRequest("GET", post.UrlMediaPath("data.txt"), nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) - - // Check the response body contains data of media file - assertions.Assert(t, rr.Body.String() == "test", "Media file data not returned") -} - -func TestHasNoDraftsInList(t *testing.T) { - repo, user := getSingleUserTestRepo() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "") - content := "" - content += "---\n" - content += "title: Articles September 2019\n" - content += "author: h4kor\n" - content += "type: post\n" - content += "date: -001-11-30T00:00:00+00:00\n" - content += "draft: true\n" - content += "url: /?p=426\n" - content += "categories:\n" - content += " - Uncategorised\n" - content += "\n" - content += "---\n" - content += "\n" - - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - // Create Request and Response - req, err := http.NewRequest("GET", "/", nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - // Check if title is in the response body - assertions.AssertNotContains(t, rr.Body.String(), "Articles September 2019") -} - -func TestSingleUserUserPostListHandler(t *testing.T) { - repo, user := getSingleUserTestRepo() - user.CreateNewPost(owl.PostMeta{ - Title: "post-1", - Type: "article", - }, "hi") - user.CreateNewPost(owl.PostMeta{ - Title: "post-2", - Type: "note", - }, "hi") - list := owl.PostList{ - Title: "list-1", - Id: "list-1", - Include: []string{"article"}, - } - user.AddPostList(list) - - // Create Request and Response - req, err := http.NewRequest("GET", user.ListUrl(list), nil) - assertions.AssertNoError(t, err, "Error creating request") - rr := httptest.NewRecorder() - router := main.SingleUserRouter(&repo) - router.ServeHTTP(rr, req) - - assertions.AssertStatus(t, rr, http.StatusOK) - - // Check the response body contains names of users - assertions.AssertContains(t, rr.Body.String(), "post-1") - assertions.AssertNotContains(t, rr.Body.String(), "post-2") -} diff --git a/cmd/owl/web/webmention_test.go b/cmd/owl/web/webmention_test.go deleted file mode 100644 index 84faf3e..0000000 --- a/cmd/owl/web/webmention_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package web_test - -import ( - "h4kor/owl-blogs" - main "h4kor/owl-blogs/cmd/owl/web" - "h4kor/owl-blogs/test/assertions" - "net/http" - "net/http/httptest" - "net/url" - "os" - "strconv" - "strings" - "testing" -) - -func setupWebmentionTest(repo owl.Repository, user owl.User, target string, source string) (*httptest.ResponseRecorder, error) { - - data := url.Values{} - data.Set("target", target) - data.Set("source", source) - - // Create Request and Response - req, err := http.NewRequest("POST", user.UrlPath()+"webmention/", strings.NewReader(data.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(data.Encode()))) - - if err != nil { - return nil, err - } - - rr := httptest.NewRecorder() - router := main.Router(&repo) - router.ServeHTTP(rr, req) - - return rr, nil -} - -func TestWebmentionHandleAccepts(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "") - - target := post.FullUrl() - source := "https://example.com" - - rr, err := setupWebmentionTest(repo, user, target, source) - assertions.AssertNoError(t, err, "Error setting up webmention test") - - assertions.AssertStatus(t, rr, http.StatusAccepted) - -} - -func TestWebmentionWrittenToPost(t *testing.T) { - - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "") - - target := post.FullUrl() - source := "https://example.com" - - rr, err := setupWebmentionTest(repo, user, target, source) - assertions.AssertNoError(t, err, "Error setting up webmention test") - - assertions.AssertStatus(t, rr, http.StatusAccepted) - assertions.AssertLen(t, post.IncomingWebmentions(), 1) -} - -// -// https://www.w3.org/TR/webmention/#h-request-verification -// - -// The receiver MUST check that source and target are valid URLs [URL] -// and are of schemes that are supported by the receiver. -// (Most commonly this means checking that the source and target schemes are http or https). -func TestWebmentionSourceValidation(t *testing.T) { - - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "") - - target := post.FullUrl() - source := "ftp://example.com" - - rr, err := setupWebmentionTest(repo, user, target, source) - assertions.AssertNoError(t, err, "Error setting up webmention test") - - assertions.AssertStatus(t, rr, http.StatusBadRequest) -} - -func TestWebmentionTargetValidation(t *testing.T) { - - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "") - - target := "ftp://example.com" - source := post.FullUrl() - - rr, err := setupWebmentionTest(repo, user, target, source) - assertions.AssertNoError(t, err, "Error setting up webmention test") - - assertions.AssertStatus(t, rr, http.StatusBadRequest) -} - -// The receiver MUST reject the request if the source URL is the same as the target URL. - -func TestWebmentionSameTargetAndSource(t *testing.T) { - - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "") - - target := post.FullUrl() - source := post.FullUrl() - - rr, err := setupWebmentionTest(repo, user, target, source) - assertions.AssertNoError(t, err, "Error setting up webmention test") - - assertions.AssertStatus(t, rr, http.StatusBadRequest) -} - -// The receiver SHOULD check that target is a valid resource for which it can accept Webmentions. -// This check SHOULD happen synchronously to reject invalid Webmentions before more in-depth verification begins. -// What a "valid resource" means is up to the receiver. -func TestValidationOfTarget(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "") - - target := post.FullUrl() - target = target[:len(target)-1] + "invalid" - source := post.FullUrl() - - rr, err := setupWebmentionTest(repo, user, target, source) - assertions.AssertNoError(t, err, "Error setting up webmention test") - - assertions.AssertStatus(t, rr, http.StatusBadRequest) -} - -func TestAcceptWebmentionForAlias(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("test-1") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "") - - content := "---\n" - content += "title: Test\n" - content += "aliases: \n" - content += " - /foo/bar\n" - content += " - /foo/baz\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - target := "https://example.com/foo/bar" - source := "https://example.com" - - rr, err := setupWebmentionTest(repo, user, target, source) - assertions.AssertNoError(t, err, "Error setting up webmention test") - - assertions.AssertStatus(t, rr, http.StatusAccepted) -} diff --git a/cmd/owl/webmention.go b/cmd/owl/webmention.go deleted file mode 100644 index 5c2dba1..0000000 --- a/cmd/owl/webmention.go +++ /dev/null @@ -1,99 +0,0 @@ -package main - -import ( - "h4kor/owl-blogs" - "sync" - - "github.com/spf13/cobra" -) - -var postId string - -func init() { - rootCmd.AddCommand(webmentionCmd) - webmentionCmd.Flags().StringVar( - &postId, "post", "", - "specify the post to send webmentions for. Otherwise, all posts will be checked.", - ) -} - -var webmentionCmd = &cobra.Command{ - Use: "webmention", - Short: "Send webmentions for posts, optionally for a specific user", - Long: `Send webmentions for posts, optionally for a specific user`, - Run: func(cmd *cobra.Command, args []string) { - repo, err := owl.OpenRepository(repoPath) - if err != nil { - println("Error opening repository: ", err.Error()) - return - } - - var users []owl.User - if user == "" { - // send webmentions for all users - users, err = repo.Users() - if err != nil { - println("Error getting users: ", err.Error()) - return - } - } else { - // send webmentions for a specific user - user, err := repo.GetUser(user) - users = append(users, user) - if err != nil { - println("Error getting user: ", err.Error()) - return - } - } - - processPost := func(user owl.User, post owl.Post) error { - println("Webmentions for post: ", post.Title()) - - err := post.ScanForLinks() - if err != nil { - println("Error scanning post for links: ", err.Error()) - return err - } - - webmentions := post.OutgoingWebmentions() - println("Found ", len(webmentions), " links") - wg := sync.WaitGroup{} - wg.Add(len(webmentions)) - for _, webmention := range webmentions { - go func(webmention owl.WebmentionOut) { - defer wg.Done() - sendErr := post.SendWebmention(webmention) - if sendErr != nil { - println("Error sending webmentions: ", sendErr.Error()) - } else { - println("Webmention sent to ", webmention.Target) - } - }(webmention) - } - wg.Wait() - return nil - } - - for _, user := range users { - if postId != "" { - // send webmentions for a specific post - post, err := user.GetPost(postId) - if err != nil { - println("Error getting post: ", err.Error()) - return - } - processPost(user, post) - return - } - - posts, err := user.PublishedPosts() - if err != nil { - println("Error getting posts: ", err.Error()) - } - - for _, post := range posts { - processPost(user, post) - } - } - }, -} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..b7ce95d --- /dev/null +++ b/config/config.go @@ -0,0 +1,25 @@ +package config + +import "os" + +const ( + SITE_CONFIG = "site_config" +) + +type Config interface { +} + +type EnvConfig struct { +} + +func getEnvOrPanic(key string) string { + value, set := os.LookupEnv(key) + if !set { + panic("Environment variable " + key + " is not set") + } + return value +} + +func NewConfig() Config { + return &EnvConfig{} +} diff --git a/directories.go b/directories.go deleted file mode 100644 index 0e377aa..0000000 --- a/directories.go +++ /dev/null @@ -1,45 +0,0 @@ -package owl - -import ( - "os" - "strings" -) - -func dirExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -// lists all files/dirs in a directory, not recursive -func listDir(path string) []string { - dir, _ := os.Open(path) - defer dir.Close() - files, _ := dir.Readdirnames(-1) - return files -} - -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -func toDirectoryName(name string) string { - name = strings.ToLower(strings.ReplaceAll(name, " ", "-")) - // remove all non-alphanumeric characters - name = strings.Map(func(r rune) rune { - if r >= 'a' && r <= 'z' { - return r - } - if r >= 'A' && r <= 'Z' { - return r - } - if r >= '0' && r <= '9' { - return r - } - if r == '-' { - return r - } - return -1 - }, name) - return name -} diff --git a/domain/model/author.go b/domain/model/author.go new file mode 100644 index 0000000..fdb514b --- /dev/null +++ b/domain/model/author.go @@ -0,0 +1,8 @@ +package model + +type Author struct { + Name string + PasswordHash string + FullUrl string + AvatarUrl string +} diff --git a/domain/model/binary_file.go b/domain/model/binary_file.go new file mode 100644 index 0000000..50450e8 --- /dev/null +++ b/domain/model/binary_file.go @@ -0,0 +1,24 @@ +package model + +import ( + "mime" + "strings" +) + +type BinaryFile struct { + Id string + Name string + Data []byte +} + +func (b *BinaryFile) Mime() string { + parts := strings.Split(b.Name, ".") + if len(parts) < 2 { + return "application/octet-stream" + } + t := mime.TypeByExtension("." + parts[len(parts)-1]) + if t == "" { + return "application/octet-stream" + } + return t +} diff --git a/domain/model/binary_file_test.go b/domain/model/binary_file_test.go new file mode 100644 index 0000000..1d5afaf --- /dev/null +++ b/domain/model/binary_file_test.go @@ -0,0 +1,13 @@ +package model_test + +import ( + "owl-blogs/domain/model" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMimeType(t *testing.T) { + bin := model.BinaryFile{Name: "test.jpg"} + require.Equal(t, "image/jpeg", bin.Mime()) +} diff --git a/domain/model/entry.go b/domain/model/entry.go new file mode 100644 index 0000000..b9372c2 --- /dev/null +++ b/domain/model/entry.go @@ -0,0 +1,54 @@ +package model + +import "time" + +type EntryContent string + +type Entry interface { + ID() string + Content() EntryContent + PublishedAt() *time.Time + AuthorId() string + MetaData() interface{} + + // Optional: can return empty string + Title() string + + SetID(id string) + SetPublishedAt(publishedAt *time.Time) + SetMetaData(metaData interface{}) + SetAuthorId(authorId string) +} + +type EntryMetaData interface { +} + +type EntryBase struct { + id string + publishedAt *time.Time + authorId string +} + +func (e *EntryBase) ID() string { + return e.id +} + +func (e *EntryBase) PublishedAt() *time.Time { + return e.publishedAt +} + +func (e *EntryBase) SetID(id string) { + e.id = id +} + +func (e *EntryBase) SetPublishedAt(publishedAt *time.Time) { + e.publishedAt = publishedAt +} + +func (e *EntryBase) AuthorId() string { + return e.authorId +} + +func (e *EntryBase) SetAuthorId(authorId string) { + e.authorId = authorId +} diff --git a/domain/model/siteconfig.go b/domain/model/siteconfig.go new file mode 100644 index 0000000..b705e8b --- /dev/null +++ b/domain/model/siteconfig.go @@ -0,0 +1,34 @@ +package model + +type MeLinks struct { + Name string + Url string +} + +type EntryList struct { + Id string + Title string + Include []string + ListType string +} + +type MenuItem struct { + Title string + List string + Url string + Post string +} + +type SiteConfig struct { + Title string + SubTitle string + HeaderColor string + AuthorName string + Me []MeLinks + Lists []EntryList + PrimaryListInclude []string + HeaderMenu []MenuItem + FooterMenu []MenuItem + Secret string + AvatarUrl string +} diff --git a/embed.go b/embed.go deleted file mode 100644 index b01283e..0000000 --- a/embed.go +++ /dev/null @@ -1,6 +0,0 @@ -package owl - -import "embed" - -//go:embed embed/* -var embed_files embed.FS diff --git a/embed/article/detail.html b/embed/article/detail.html deleted file mode 100644 index 63e8349..0000000 --- a/embed/article/detail.html +++ /dev/null @@ -1,60 +0,0 @@ -
-
-

{{.Title}}

- - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
-
-
- - {{ if .Post.Meta.Bookmark.Url }} -

- Bookmark: - {{ if .Post.Meta.Bookmark.Text }} - {{.Post.Meta.Bookmark.Text}} - {{ else }} - {{.Post.Meta.Bookmark.Url}} - {{ end }} - -

- {{ end }} - -
- {{.Content}} -
- -
- {{if .Post.ApprovedIncomingWebmentions}} -

- Webmentions -

- - {{end}} -
\ No newline at end of file diff --git a/embed/auth.html b/embed/auth.html deleted file mode 100644 index 1e4eec1..0000000 --- a/embed/auth.html +++ /dev/null @@ -1,24 +0,0 @@ -

Authorization for {{.ClientId}}

- -
Requesting scope:
-
    - {{range $index, $element := .Scopes}} -
  • {{$element}}
  • - {{end}} -
- -

- -
- - - - - - - - - - - -
\ No newline at end of file diff --git a/embed/bookmark/detail.html b/embed/bookmark/detail.html deleted file mode 100644 index ece34bc..0000000 --- a/embed/bookmark/detail.html +++ /dev/null @@ -1,72 +0,0 @@ -
-
-

{{.Title}}

- - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
-
-
- - {{ if .Post.Meta.Reply.Url }} -

- In reply to: - {{ if .Post.Meta.Reply.Text }} - {{.Post.Meta.Reply.Text}} - {{ else }} - {{.Post.Meta.Reply.Url}} - {{ end }} - -

- {{ end }} - - {{ if .Post.Meta.Bookmark.Url }} -

- Bookmark: - {{ if .Post.Meta.Bookmark.Text }} - {{.Post.Meta.Bookmark.Text}} - {{ else }} - {{.Post.Meta.Bookmark.Url}} - {{ end }} - -

- {{ end }} - -
- {{.Content}} -
- -
- {{if .Post.ApprovedIncomingWebmentions}} -

- Webmentions -

- - {{end}} -
\ No newline at end of file diff --git a/embed/editor/editor.html b/embed/editor/editor.html deleted file mode 100644 index 7e23604..0000000 --- a/embed/editor/editor.html +++ /dev/null @@ -1,127 +0,0 @@ -
- Write Article/Page -
-

Create New Article

- - - - - - - - - - -

- -
-
- -
- Upload Photo -
-

Upload Photo

- - - - - - - - - - - - - -

- -
-
- -
- Write Recipe -
-

Create new Recipe

- - - - - - - - - - - - - - - - - - - - -

- -
-
- -
- Write Note -
-

Create New Note

- - - - -

- -
-
- -
- Write Reply -
-

Create New Reply

- - - - - - - - - - - - -

- -
-
- -
- Bookmark -
-

Create Bookmark

- - - - - - - - - - - - -

- -
-
\ No newline at end of file diff --git a/embed/editor/login.html b/embed/editor/login.html deleted file mode 100644 index f3e7dc4..0000000 --- a/embed/editor/login.html +++ /dev/null @@ -1,13 +0,0 @@ -{{ if eq .Error "wrong_password" }} -
- Wrong Password -
-{{ end }} - - -
-

Login to Editor

- - - -
\ No newline at end of file diff --git a/embed/error.html b/embed/error.html deleted file mode 100644 index e3000ab..0000000 --- a/embed/error.html +++ /dev/null @@ -1,4 +0,0 @@ -
-

{{ .Error }}

- {{ .Message }} -
\ No newline at end of file diff --git a/embed/initial/base.html b/embed/initial/base.html deleted file mode 100644 index d9993ad..0000000 --- a/embed/initial/base.html +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - - - {{ .Title }} - {{ .User.Config.Title }} - - {{ if .User.FaviconUrl }} - - {{ else }} - - {{ end }} - - - {{ if .Description }} - - - {{ end }} - {{ if .Type }} - - {{ end }} - {{ if .SelfUrl }} - - {{ end }} - - - - {{ if .User.AuthUrl }} - - - - - {{ end }} - - - - -
-
-
-

{{ .User.Config.Title }}

-

{{ .User.Config.SubTitle }}

-
- -
- {{ if .User.AvatarUrl }} - - {{ end }} -
- {{ range $me := .User.Config.Me }} -
  • {{$me.Name}} -
  • - {{ end }} -
    -
    -
    -
    - -
    -
    - {{ .Content }} - - - - - diff --git a/embed/initial/header.html b/embed/initial/header.html deleted file mode 100644 index dc71910..0000000 --- a/embed/initial/header.html +++ /dev/null @@ -1,5 +0,0 @@ -
      - {{ range .UserLinks }} -
    • {{.Text}}
    • - {{ end }} -
    \ No newline at end of file diff --git a/embed/initial/repo/base.html b/embed/initial/repo/base.html deleted file mode 100644 index 4afaa2b..0000000 --- a/embed/initial/repo/base.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - {{ .Title }} - - - - - {{ .Content }} - - - \ No newline at end of file diff --git a/embed/initial/static/pico.min.css b/embed/initial/static/pico.min.css deleted file mode 100644 index a4fbbd8..0000000 --- a/embed/initial/static/pico.min.css +++ /dev/null @@ -1,5 +0,0 @@ -@charset "UTF-8";/*! - * Pico.css v1.5.3 (https://picocss.com) - * Copyright 2019-2022 - Licensed under MIT - */:root{--font-family:system-ui,-apple-system,"Segoe UI","Roboto","Ubuntu","Cantarell","Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--line-height:1.5;--font-weight:400;--font-size:16px;--border-radius:0.25rem;--border-width:1px;--outline-width:3px;--spacing:1rem;--typography-spacing-vertical:1.5rem;--block-spacing-vertical:calc(var(--spacing) * 2);--block-spacing-horizontal:var(--spacing);--grid-spacing-vertical:0;--grid-spacing-horizontal:var(--spacing);--form-element-spacing-vertical:0.75rem;--form-element-spacing-horizontal:1rem;--nav-element-spacing-vertical:1rem;--nav-element-spacing-horizontal:0.5rem;--nav-link-spacing-vertical:0.5rem;--nav-link-spacing-horizontal:0.5rem;--form-label-font-weight:var(--font-weight);--transition:0.2s ease-in-out}@media (min-width:576px){:root{--font-size:17px}}@media (min-width:768px){:root{--font-size:18px}}@media (min-width:992px){:root{--font-size:19px}}@media (min-width:1200px){:root{--font-size:20px}}@media (min-width:576px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 2.5)}}@media (min-width:768px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 3)}}@media (min-width:992px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 3.5)}}@media (min-width:1200px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 4)}}@media (min-width:576px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.25)}}@media (min-width:768px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.5)}}@media (min-width:992px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.75)}}@media (min-width:1200px){article{--block-spacing-horizontal:calc(var(--spacing) * 2)}}dialog>article{--block-spacing-vertical:calc(var(--spacing) * 2);--block-spacing-horizontal:var(--spacing)}@media (min-width:576px){dialog>article{--block-spacing-vertical:calc(var(--spacing) * 2.5);--block-spacing-horizontal:calc(var(--spacing) * 1.25)}}@media (min-width:768px){dialog>article{--block-spacing-vertical:calc(var(--spacing) * 3);--block-spacing-horizontal:calc(var(--spacing) * 1.5)}}a{--text-decoration:none}a.contrast,a.secondary{--text-decoration:underline}small{--font-size:0.875em}h1,h2,h3,h4,h5,h6{--font-weight:700}h1{--font-size:2rem;--typography-spacing-vertical:3rem}h2{--font-size:1.75rem;--typography-spacing-vertical:2.625rem}h3{--font-size:1.5rem;--typography-spacing-vertical:2.25rem}h4{--font-size:1.25rem;--typography-spacing-vertical:1.874rem}h5{--font-size:1.125rem;--typography-spacing-vertical:1.6875rem}[type=checkbox],[type=radio]{--border-width:2px}[type=checkbox][role=switch]{--border-width:3px}tfoot td,tfoot th,thead td,thead th{--border-width:3px}:not(thead):not(tfoot)>*>td{--font-size:0.875em}code,kbd,pre,samp{--font-family:"Menlo","Consolas","Roboto Mono","Ubuntu Monospace","Noto Mono","Oxygen Mono","Liberation Mono",monospace,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"}kbd{--font-weight:bolder}:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--background-color:#fff;--color:hsl(205deg, 20%, 32%);--h1-color:hsl(205deg, 30%, 15%);--h2-color:#24333e;--h3-color:hsl(205deg, 25%, 23%);--h4-color:#374956;--h5-color:hsl(205deg, 20%, 32%);--h6-color:#4d606d;--muted-color:hsl(205deg, 10%, 50%);--muted-border-color:hsl(205deg, 20%, 94%);--primary:hsl(195deg, 85%, 41%);--primary-hover:hsl(195deg, 90%, 32%);--primary-focus:rgba(16, 149, 193, 0.125);--primary-inverse:#fff;--secondary:hsl(205deg, 15%, 41%);--secondary-hover:hsl(205deg, 20%, 32%);--secondary-focus:rgba(89, 107, 120, 0.125);--secondary-inverse:#fff;--contrast:hsl(205deg, 30%, 15%);--contrast-hover:#000;--contrast-focus:rgba(89, 107, 120, 0.125);--contrast-inverse:#fff;--mark-background-color:#fff2ca;--mark-color:#543a26;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:transparent;--form-element-border-color:hsl(205deg, 14%, 68%);--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:transparent;--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:hsl(205deg, 18%, 86%);--form-element-disabled-border-color:hsl(205deg, 14%, 68%);--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#c62828;--form-element-invalid-active-border-color:#d32f2f;--form-element-invalid-focus-color:rgba(211, 47, 47, 0.125);--form-element-valid-border-color:#388e3c;--form-element-valid-active-border-color:#43a047;--form-element-valid-focus-color:rgba(67, 160, 71, 0.125);--switch-background-color:hsl(205deg, 16%, 77%);--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:hsl(205deg, 18%, 86%);--range-active-border-color:hsl(205deg, 16%, 77%);--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:#f6f8f9;--code-background-color:hsl(205deg, 20%, 94%);--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:hsl(330deg, 40%, 50%);--code-property-color:hsl(185deg, 40%, 40%);--code-value-color:hsl(40deg, 20%, 50%);--code-comment-color:hsl(205deg, 14%, 68%);--accordion-border-color:var(--muted-border-color);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:var(--background-color);--card-border-color:var(--muted-border-color);--card-box-shadow:0.0145rem 0.029rem 0.174rem rgba(27, 40, 50, 0.01698),0.0335rem 0.067rem 0.402rem rgba(27, 40, 50, 0.024),0.0625rem 0.125rem 0.75rem rgba(27, 40, 50, 0.03),0.1125rem 0.225rem 1.35rem rgba(27, 40, 50, 0.036),0.2085rem 0.417rem 2.502rem rgba(27, 40, 50, 0.04302),0.5rem 1rem 6rem rgba(27, 40, 50, 0.06),0 0 0 0.0625rem rgba(27, 40, 50, 0.015);--card-sectionning-background-color:#fbfbfc;--dropdown-background-color:#fbfbfc;--dropdown-border-color:#e1e6eb;--dropdown-box-shadow:var(--card-box-shadow);--dropdown-color:var(--color);--dropdown-hover-background-color:hsl(205deg, 20%, 94%);--modal-overlay-background-color:rgba(213, 220, 226, 0.8);--progress-background-color:hsl(205deg, 18%, 86%);--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(65, 84, 98, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(255, 255, 255, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button-inverse:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(255, 255, 255, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(115, 130, 140, 0.999)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(65, 84, 98, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(198, 40, 40, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(65, 84, 98, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(65, 84, 98, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(56, 142, 60, 0.999)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E")}@media only screen and (prefers-color-scheme:dark){:root:not([data-theme=light]){color-scheme:dark;--background-color:#11191f;--color:hsl(205deg, 16%, 77%);--h1-color:hsl(205deg, 20%, 94%);--h2-color:#e1e6eb;--h3-color:hsl(205deg, 18%, 86%);--h4-color:#c8d1d8;--h5-color:hsl(205deg, 16%, 77%);--h6-color:#afbbc4;--muted-color:hsl(205deg, 10%, 50%);--muted-border-color:#1f2d38;--primary:hsl(195deg, 85%, 41%);--primary-hover:hsl(195deg, 80%, 50%);--primary-focus:rgba(16, 149, 193, 0.25);--primary-inverse:#fff;--secondary:hsl(205deg, 15%, 41%);--secondary-hover:hsl(205deg, 10%, 50%);--secondary-focus:rgba(115, 130, 140, 0.25);--secondary-inverse:#fff;--contrast:hsl(205deg, 20%, 94%);--contrast-hover:#fff;--contrast-focus:rgba(115, 130, 140, 0.25);--contrast-inverse:#000;--mark-background-color:#d1c284;--mark-color:#11191f;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:#11191f;--form-element-border-color:#374956;--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:var(--form-element-background-color);--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:hsl(205deg, 25%, 23%);--form-element-disabled-border-color:hsl(205deg, 20%, 32%);--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#b71c1c;--form-element-invalid-active-border-color:#c62828;--form-element-invalid-focus-color:rgba(198, 40, 40, 0.25);--form-element-valid-border-color:#2e7d32;--form-element-valid-active-border-color:#388e3c;--form-element-valid-focus-color:rgba(56, 142, 60, 0.25);--switch-background-color:#374956;--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:#24333e;--range-active-border-color:hsl(205deg, 25%, 23%);--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:rgba(115, 130, 140, 0.05);--code-background-color:#18232c;--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:hsl(330deg, 30%, 50%);--code-property-color:hsl(185deg, 30%, 50%);--code-value-color:hsl(40deg, 10%, 50%);--code-comment-color:#4d606d;--accordion-border-color:var(--muted-border-color);--accordion-active-summary-color:var(--primary);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:#141e26;--card-border-color:var(--card-background-color);--card-box-shadow:0.0145rem 0.029rem 0.174rem rgba(0, 0, 0, 0.01698),0.0335rem 0.067rem 0.402rem rgba(0, 0, 0, 0.024),0.0625rem 0.125rem 0.75rem rgba(0, 0, 0, 0.03),0.1125rem 0.225rem 1.35rem rgba(0, 0, 0, 0.036),0.2085rem 0.417rem 2.502rem rgba(0, 0, 0, 0.04302),0.5rem 1rem 6rem rgba(0, 0, 0, 0.06),0 0 0 0.0625rem rgba(0, 0, 0, 0.015);--card-sectionning-background-color:#18232c;--dropdown-background-color:hsl(205deg, 30%, 15%);--dropdown-border-color:#24333e;--dropdown-box-shadow:var(--card-box-shadow);--dropdown-color:var(--color);--dropdown-hover-background-color:rgba(36, 51, 62, 0.75);--modal-overlay-background-color:rgba(36, 51, 62, 0.9);--progress-background-color:#24333e;--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(255, 255, 255, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button-inverse:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(0, 0, 0, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(115, 130, 140, 0.999)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(183, 28, 28, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(46, 125, 50, 0.999)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E")}}[data-theme=dark]{color-scheme:dark;--background-color:#11191f;--color:hsl(205deg, 16%, 77%);--h1-color:hsl(205deg, 20%, 94%);--h2-color:#e1e6eb;--h3-color:hsl(205deg, 18%, 86%);--h4-color:#c8d1d8;--h5-color:hsl(205deg, 16%, 77%);--h6-color:#afbbc4;--muted-color:hsl(205deg, 10%, 50%);--muted-border-color:#1f2d38;--primary:hsl(195deg, 85%, 41%);--primary-hover:hsl(195deg, 80%, 50%);--primary-focus:rgba(16, 149, 193, 0.25);--primary-inverse:#fff;--secondary:hsl(205deg, 15%, 41%);--secondary-hover:hsl(205deg, 10%, 50%);--secondary-focus:rgba(115, 130, 140, 0.25);--secondary-inverse:#fff;--contrast:hsl(205deg, 20%, 94%);--contrast-hover:#fff;--contrast-focus:rgba(115, 130, 140, 0.25);--contrast-inverse:#000;--mark-background-color:#d1c284;--mark-color:#11191f;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:#11191f;--form-element-border-color:#374956;--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:var(--form-element-background-color);--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:hsl(205deg, 25%, 23%);--form-element-disabled-border-color:hsl(205deg, 20%, 32%);--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#b71c1c;--form-element-invalid-active-border-color:#c62828;--form-element-invalid-focus-color:rgba(198, 40, 40, 0.25);--form-element-valid-border-color:#2e7d32;--form-element-valid-active-border-color:#388e3c;--form-element-valid-focus-color:rgba(56, 142, 60, 0.25);--switch-background-color:#374956;--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:#24333e;--range-active-border-color:hsl(205deg, 25%, 23%);--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:rgba(115, 130, 140, 0.05);--code-background-color:#18232c;--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:hsl(330deg, 30%, 50%);--code-property-color:hsl(185deg, 30%, 50%);--code-value-color:hsl(40deg, 10%, 50%);--code-comment-color:#4d606d;--accordion-border-color:var(--muted-border-color);--accordion-active-summary-color:var(--primary);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:#141e26;--card-border-color:var(--card-background-color);--card-box-shadow:0.0145rem 0.029rem 0.174rem rgba(0, 0, 0, 0.01698),0.0335rem 0.067rem 0.402rem rgba(0, 0, 0, 0.024),0.0625rem 0.125rem 0.75rem rgba(0, 0, 0, 0.03),0.1125rem 0.225rem 1.35rem rgba(0, 0, 0, 0.036),0.2085rem 0.417rem 2.502rem rgba(0, 0, 0, 0.04302),0.5rem 1rem 6rem rgba(0, 0, 0, 0.06),0 0 0 0.0625rem rgba(0, 0, 0, 0.015);--card-sectionning-background-color:#18232c;--dropdown-background-color:hsl(205deg, 30%, 15%);--dropdown-border-color:#24333e;--dropdown-box-shadow:var(--card-box-shadow);--dropdown-color:var(--color);--dropdown-hover-background-color:rgba(36, 51, 62, 0.75);--modal-overlay-background-color:rgba(36, 51, 62, 0.9);--progress-background-color:#24333e;--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(255, 255, 255, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button-inverse:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(0, 0, 0, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(115, 130, 140, 0.999)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(183, 28, 28, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(46, 125, 50, 0.999)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E")}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;text-rendering:optimizeLegibility;background-color:var(--background-color);color:var(--color);font-weight:var(--font-weight);font-size:var(--font-size);line-height:var(--line-height);font-family:var(--font-family);overflow-wrap:break-word;cursor:default;-moz-tab-size:4;-o-tab-size:4;tab-size:4}main{display:block}body{width:100%;margin:0}body>footer,body>header,body>main{width:100%;margin-right:auto;margin-left:auto;padding:var(--block-spacing-vertical) 0}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--spacing);padding-left:var(--spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:992px){.container{max-width:920px}}@media (min-width:1200px){.container{max-width:1130px}}section{margin-bottom:var(--block-spacing-vertical)}.grid{grid-column-gap:var(--grid-spacing-horizontal);grid-row-gap:var(--grid-spacing-vertical);display:grid;grid-template-columns:1fr;margin:0}@media (min-width:992px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}figure{display:block;margin:0;padding:0;overflow-x:auto}figure figcaption{padding:calc(var(--spacing) * .5) 0;color:var(--muted-color)}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,figure,form,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--typography-spacing-vertical);color:var(--color);font-style:normal;font-weight:var(--font-weight);font-size:var(--font-size)}[role=link],a{--color:var(--primary);--background-color:transparent;outline:0;background-color:var(--background-color);color:var(--color);-webkit-text-decoration:var(--text-decoration);text-decoration:var(--text-decoration);transition:background-color var(--transition),color var(--transition),box-shadow var(--transition),-webkit-text-decoration var(--transition);transition:background-color var(--transition),color var(--transition),text-decoration var(--transition),box-shadow var(--transition);transition:background-color var(--transition),color var(--transition),text-decoration var(--transition),box-shadow var(--transition),-webkit-text-decoration var(--transition)}[role=link]:is([aria-current],:hover,:active,:focus),a:is([aria-current],:hover,:active,:focus){--color:var(--primary-hover);--text-decoration:underline}[role=link]:focus,a:focus{--background-color:var(--primary-focus)}[role=link].secondary,a.secondary{--color:var(--secondary)}[role=link].secondary:is([aria-current],:hover,:active,:focus),a.secondary:is([aria-current],:hover,:active,:focus){--color:var(--secondary-hover)}[role=link].secondary:focus,a.secondary:focus{--background-color:var(--secondary-focus)}[role=link].contrast,a.contrast{--color:var(--contrast)}[role=link].contrast:is([aria-current],:hover,:active,:focus),a.contrast:is([aria-current],:hover,:active,:focus){--color:var(--contrast-hover)}[role=link].contrast:focus,a.contrast:focus{--background-color:var(--contrast-focus)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--typography-spacing-vertical);color:var(--color);font-weight:var(--font-weight);font-size:var(--font-size);font-family:var(--font-family)}h1{--color:var(--h1-color)}h2{--color:var(--h2-color)}h3{--color:var(--h3-color)}h4{--color:var(--h4-color)}h5{--color:var(--h5-color)}h6{--color:var(--h6-color)}:where(address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--typography-spacing-vertical)}.headings,hgroup{margin-bottom:var(--typography-spacing-vertical)}.headings>*,hgroup>*{margin-bottom:0}.headings>:last-child,hgroup>:last-child{--color:var(--muted-color);--font-weight:unset;font-size:1rem;font-family:unset}p{margin-bottom:var(--typography-spacing-vertical)}small{font-size:var(--font-size)}:where(dl,ol,ul){padding-right:0;padding-left:var(--spacing);-webkit-padding-start:var(--spacing);padding-inline-start:var(--spacing);-webkit-padding-end:0;padding-inline-end:0}:where(dl,ol,ul) li{margin-bottom:calc(var(--typography-spacing-vertical) * .25)}:where(dl,ol,ul) :is(dl,ol,ul){margin:0;margin-top:calc(var(--typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--mark-background-color);color:var(--mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--typography-spacing-vertical) 0;padding:var(--spacing);border-right:none;border-left:.25rem solid var(--blockquote-border-color);-webkit-border-start:0.25rem solid var(--blockquote-border-color);border-inline-start:0.25rem solid var(--blockquote-border-color);-webkit-border-end:none;border-inline-end:none}blockquote footer{margin-top:calc(var(--typography-spacing-vertical) * .5);color:var(--blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--ins-color);text-decoration:none}del{color:var(--del-color)}::-moz-selection{background-color:var(--primary-focus)}::selection{background-color:var(--primary-focus)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:root){overflow:hidden}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}button{display:block;width:100%;margin-bottom:var(--spacing)}[role=button]{display:inline-block;text-decoration:none}[role=button],button,input[type=button],input[type=reset],input[type=submit]{--background-color:var(--primary);--border-color:var(--primary);--color:var(--primary-inverse);--box-shadow:var(--button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[role=button]:is([aria-current],:hover,:active,:focus),button:is([aria-current],:hover,:active,:focus),input[type=button]:is([aria-current],:hover,:active,:focus),input[type=reset]:is([aria-current],:hover,:active,:focus),input[type=submit]:is([aria-current],:hover,:active,:focus){--background-color:var(--primary-hover);--border-color:var(--primary-hover);--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--color:var(--primary-inverse)}[role=button]:focus,button:focus,input[type=button]:focus,input[type=reset]:focus,input[type=submit]:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--primary-focus)}:is(button,input[type=submit],input[type=button],[role=button]).secondary,input[type=reset]{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);cursor:pointer}:is(button,input[type=submit],input[type=button],[role=button]).secondary:is([aria-current],:hover,:active,:focus),input[type=reset]:is([aria-current],:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover);--color:var(--secondary-inverse)}:is(button,input[type=submit],input[type=button],[role=button]).secondary:focus,input[type=reset]:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--secondary-focus)}:is(button,input[type=submit],input[type=button],[role=button]).contrast{--background-color:var(--contrast);--border-color:var(--contrast);--color:var(--contrast-inverse)}:is(button,input[type=submit],input[type=button],[role=button]).contrast:is([aria-current],:hover,:active,:focus){--background-color:var(--contrast-hover);--border-color:var(--contrast-hover);--color:var(--contrast-inverse)}:is(button,input[type=submit],input[type=button],[role=button]).contrast:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--contrast-focus)}:is(button,input[type=submit],input[type=button],[role=button]).outline,input[type=reset].outline{--background-color:transparent;--color:var(--primary)}:is(button,input[type=submit],input[type=button],[role=button]).outline:is([aria-current],:hover,:active,:focus),input[type=reset].outline:is([aria-current],:hover,:active,:focus){--background-color:transparent;--color:var(--primary-hover)}:is(button,input[type=submit],input[type=button],[role=button]).outline.secondary,input[type=reset].outline{--color:var(--secondary)}:is(button,input[type=submit],input[type=button],[role=button]).outline.secondary:is([aria-current],:hover,:active,:focus),input[type=reset].outline:is([aria-current],:hover,:active,:focus){--color:var(--secondary-hover)}:is(button,input[type=submit],input[type=button],[role=button]).outline.contrast{--color:var(--contrast)}:is(button,input[type=submit],input[type=button],[role=button]).outline.contrast:is([aria-current],:hover,:active,:focus){--color:var(--contrast-hover)}:where(button,[type=submit],[type=button],[type=reset],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]),a[role=button]:not([href]){opacity:.5;pointer-events:none}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox]):not([type=radio]):not([type=range]){height:calc(1rem * var(--line-height) + var(--form-element-spacing-vertical) * 2 + var(--border-width) * 2)}fieldset{margin:0;margin-bottom:var(--spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--spacing) * .25);font-weight:var(--form-label-font-weight,var(--font-weight))}input:not([type=checkbox]):not([type=radio]),select,textarea{width:100%}input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal)}input,select,textarea{--background-color:var(--form-element-background-color);--border-color:var(--form-element-border-color);--color:var(--form-element-color);--box-shadow:none;border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}:where(select,textarea):is(:active,:focus),input:not([type=submit]):not([type=button]):not([type=reset]):not([type=checkbox]):not([type=radio]):not([readonly]):is(:active,:focus){--background-color:var(--form-element-active-background-color)}:where(select,textarea):is(:active,:focus),input:not([type=submit]):not([type=button]):not([type=reset]):not([role=switch]):not([readonly]):is(:active,:focus){--border-color:var(--form-element-active-border-color)}input:not([type=submit]):not([type=button]):not([type=reset]):not([type=range]):not([type=file]):not([readonly]):focus,select:focus,textarea:focus{--box-shadow:0 0 0 var(--outline-width) var(--form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit]):not([type=button]):not([type=reset]),select,textarea),input:not([type=submit]):not([type=button]):not([type=reset])[disabled],select[disabled],textarea[disabled]{--background-color:var(--form-element-disabled-background-color);--border-color:var(--form-element-disabled-border-color);opacity:var(--form-element-disabled-opacity);pointer-events:none}:where(input,select,textarea):not([type=checkbox]):not([type=radio])[aria-invalid]{padding-right:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--form-element-spacing-horizontal);-webkit-padding-start:var(--form-element-spacing-horizontal)!important;padding-inline-start:var(--form-element-spacing-horizontal)!important;-webkit-padding-end:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;padding-inline-end:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox]):not([type=radio])[aria-invalid=false]{background-image:var(--icon-valid)}:where(input,select,textarea):not([type=checkbox]):not([type=radio])[aria-invalid=true]{background-image:var(--icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--border-color:var(--form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--border-color:var(--form-element-valid-active-border-color)!important;--box-shadow:0 0 0 var(--outline-width) var(--form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--border-color:var(--form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--border-color:var(--form-element-invalid-active-border-color)!important;--box-shadow:0 0 0 var(--outline-width) var(--form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox]):not([type=radio])[aria-invalid=false],[dir=rtl] :where(input,select,textarea):not([type=checkbox]):not([type=radio])[aria-invalid=true],[dir=rtl] :where(input,select,textarea):not([type=checkbox]):not([type=radio])[aria-invalid]{background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--form-element-placeholder-color);opacity:1}input:not([type=checkbox]):not([type=radio]),select,textarea{margin-bottom:var(--spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple]):not([size]){padding-right:calc(var(--form-element-spacing-horizontal) + 1.5rem);padding-left:var(--form-element-spacing-horizontal);-webkit-padding-start:var(--form-element-spacing-horizontal);padding-inline-start:var(--form-element-spacing-horizontal);-webkit-padding-end:calc(var(--form-element-spacing-horizontal) + 1.5rem);padding-inline-end:calc(var(--form-element-spacing-horizontal) + 1.5rem);background-image:var(--icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}[dir=rtl] select:not([multiple]):not([size]){background-position:center left .75rem}:where(input,select,textarea)+small{display:block;width:100%;margin-top:calc(var(--spacing) * -.75);margin-bottom:var(--spacing);color:var(--muted-color)}label>:where(input,select,textarea){margin-top:calc(var(--spacing) * .25)}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-right:.375em;margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:.375em;margin-inline-end:.375em;border-width:var(--border-width);font-size:inherit;vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--background-color:var(--primary);--border-color:var(--primary);background-image:var(--icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-right:.375em;margin-bottom:0;cursor:pointer}[type=checkbox]:indeterminate{--background-color:var(--primary);--border-color:var(--primary);background-image:var(--icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--background-color:var(--primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--background-color:var(--switch-background-color);--border-color:var(--switch-background-color);--color:var(--switch-color);width:2.25em;height:1.25em;border:var(--border-width) solid var(--border-color);border-radius:1.25em;background-color:var(--background-color);line-height:1.25em}[type=checkbox][role=switch]:focus{--background-color:var(--switch-background-color);--border-color:var(--switch-background-color)}[type=checkbox][role=switch]:checked{--background-color:var(--switch-checked-background-color);--border-color:var(--switch-checked-background-color)}[type=checkbox][role=switch]:before{display:block;width:calc(1.25em - (var(--border-width) * 2));height:100%;border-radius:50%;background-color:var(--color);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:checked{background-image:none}[type=checkbox][role=switch]:checked::before{margin-left:calc(1.125em - var(--border-width));-webkit-margin-start:calc(1.125em - var(--border-width));margin-inline-start:calc(1.125em - var(--border-width))}[type=checkbox]:checked[aria-invalid=false],[type=checkbox][aria-invalid=false],[type=checkbox][role=switch]:checked[aria-invalid=false],[type=checkbox][role=switch][aria-invalid=false],[type=radio]:checked[aria-invalid=false],[type=radio][aria-invalid=false]{--border-color:var(--form-element-valid-border-color)}[type=checkbox]:checked[aria-invalid=true],[type=checkbox][aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=checkbox][role=switch][aria-invalid=true],[type=radio]:checked[aria-invalid=true],[type=radio][aria-invalid=true]{--border-color:var(--form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--border-radius) * .5)}input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=date],input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=datetime-local],input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=month],input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=time],input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=week]{--icon-position:0.75rem;--icon-width:1rem;padding-right:calc(var(--icon-width) + var(--icon-position));background-image:var(--icon-date);background-position:center right var(--icon-position);background-size:var(--icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=time]{background-image:var(--icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--icon-width);margin-right:calc(var(--icon-width) * -1);margin-left:var(--icon-position);opacity:0}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--color:var(--muted-color);padding:calc(var(--form-element-spacing-vertical) * .5) 0;border:0;border-radius:0;background:0 0}[type=file]::-webkit-file-upload-button{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing)/ 2);margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;-webkit-transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::file-selector-button{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing)/ 2);margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::-webkit-file-upload-button:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=file]::file-selector-button:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=file]::-webkit-file-upload-button{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing)/ 2);margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;-webkit-transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::-webkit-file-upload-button:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=file]::-ms-browse{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;margin-inline-start:0;margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;-ms-transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::-ms-browse:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-webkit-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-moz-range-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-moz-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-ms-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-ms-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-moz-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-ms-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]:focus,[type=range]:hover{--range-border-color:var(--range-active-border-color);--range-thumb-color:var(--range-thumb-hover-color)}[type=range]:active{--range-thumb-color:var(--range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=search]{-webkit-padding-start:calc(var(--form-element-spacing-horizontal) + 1.75rem);padding-inline-start:calc(var(--form-element-spacing-horizontal) + 1.75rem);border-radius:5rem;background-image:var(--icon-search);background-position:center left 1.125rem;background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=search][aria-invalid]{-webkit-padding-start:calc(var(--form-element-spacing-horizontal) + 1.75rem)!important;padding-inline-start:calc(var(--form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=search][aria-invalid=false]{background-image:var(--icon-search),var(--icon-valid)}input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=search][aria-invalid=true]{background-image:var(--icon-search),var(--icon-invalid)}[type=search]::-webkit-search-cancel-button{-webkit-appearance:none;display:none}[dir=rtl] :where(input):not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--spacing)/ 2) var(--spacing);border-bottom:var(--border-width) solid var(--table-border-color);color:var(--color);font-weight:var(--font-weight);font-size:var(--font-size);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--border-width) solid var(--table-border-color);border-bottom:0}table[role=grid] tbody tr:nth-child(odd){background-color:var(--table-row-stripped-background-color)}code,kbd,pre,samp{font-size:.875em;font-family:var(--font-family)}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre{border-radius:var(--border-radius);background:var(--code-background-color);color:var(--code-color);font-weight:var(--font-weight);line-height:initial}code,kbd{display:inline-block;padding:.375rem .5rem}pre{display:block;margin-bottom:var(--spacing);overflow-x:auto}pre>code{display:block;padding:var(--spacing);background:0 0;font-size:14px;line-height:var(--line-height)}code b{color:var(--code-tag-color);font-weight:var(--font-weight)}code i{color:var(--code-property-color);font-style:normal}code u{color:var(--code-value-color);text-decoration:none}code em{color:var(--code-comment-color);font-style:normal}kbd{background-color:var(--code-kbd-background-color);color:var(--code-kbd-color);vertical-align:baseline}hr{height:0;border:0;border-top:1px solid var(--muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}details{display:block;margin-bottom:var(--spacing);padding-bottom:var(--spacing);border-bottom:var(--border-width) solid var(--accordion-border-color)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--transition)}details summary:not([role]){color:var(--accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;-webkit-margin-start:calc(var(--spacing,1rem) * 0.5);margin-inline-start:calc(var(--spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--transition)}details summary:focus{outline:0}details summary:focus:not([role=button]){color:var(--accordion-active-summary-color)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--line-height,1.5));background-image:var(--icon-chevron-button)}details summary[role=button]:not(.outline).contrast::after{background-image:var(--icon-chevron-button-inverse)}details[open]>summary{margin-bottom:calc(var(--spacing))}details[open]>summary:not([role]):not(:focus){color:var(--accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin:var(--block-spacing-vertical) 0;padding:var(--block-spacing-vertical) var(--block-spacing-horizontal);border-radius:var(--border-radius);background:var(--card-background-color);box-shadow:var(--card-box-shadow)}article>footer,article>header{margin-right:calc(var(--block-spacing-horizontal) * -1);margin-left:calc(var(--block-spacing-horizontal) * -1);padding:calc(var(--block-spacing-vertical) * .66) var(--block-spacing-horizontal);background-color:var(--card-sectionning-background-color)}article>header{margin-top:calc(var(--block-spacing-vertical) * -1);margin-bottom:var(--block-spacing-vertical);border-bottom:var(--border-width) solid var(--card-border-color);border-top-right-radius:var(--border-radius);border-top-left-radius:var(--border-radius)}article>footer{margin-top:var(--block-spacing-vertical);margin-bottom:calc(var(--block-spacing-vertical) * -1);border-top:var(--border-width) solid var(--card-border-color);border-bottom-right-radius:var(--border-radius);border-bottom-left-radius:var(--border-radius)}:root{--scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:var(--spacing);border:0;background-color:var(--modal-overlay-background-color);color:var(--color)}dialog article{max-height:calc(100vh - var(--spacing) * 2);overflow:auto}@media (min-width:576px){dialog article{max-width:510px}}@media (min-width:768px){dialog article{max-width:700px}}dialog article>footer,dialog article>header{padding:calc(var(--block-spacing-vertical) * .5) var(--block-spacing-horizontal)}dialog article>header .close{margin:0;margin-left:var(--spacing);float:right}dialog article>footer{text-align:right}dialog article>footer [role=button]{margin-bottom:0}dialog article>footer [role=button]:not(:first-of-type){margin-left:calc(var(--spacing) * .5)}dialog article p:last-of-type{margin:0}dialog article .close{display:block;width:1rem;height:1rem;margin-top:calc(var(--block-spacing-vertical) * -.5);margin-bottom:var(--typography-spacing-vertical);margin-left:auto;background-image:var(--icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;opacity:.5;transition:opacity var(--transition)}dialog article .close:is([aria-current],:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--scrollbar-width,0);overflow:hidden;pointer-events:none}.modal-is-open dialog{pointer-events:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{-webkit-animation-duration:.2s;animation-duration:.2s;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;-webkit-animation-fill-mode:both;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{-webkit-animation-duration:.8s;animation-duration:.8s;-webkit-animation-name:fadeIn;animation-name:fadeIn}:where(.modal-is-opening,.modal-is-closing) dialog>article{-webkit-animation-delay:.2s;animation-delay:.2s;-webkit-animation-name:slideInDown;animation-name:slideInDown}.modal-is-closing dialog,.modal-is-closing dialog>article{-webkit-animation-delay:0s;animation-delay:0s;animation-direction:reverse}@-webkit-keyframes fadeIn{from{background-color:transparent}to{background-color:var(--modal-overlay-background-color)}}@keyframes fadeIn{from{background-color:transparent}to{background-color:var(--modal-overlay-background-color)}}@-webkit-keyframes slideInDown{from{transform:translateY(-100%);opacity:0}to{transform:translateY(0);opacity:1}}@keyframes slideInDown{from{transform:translateY(-100%);opacity:0}to{transform:translateY(0);opacity:1}}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--nav-element-spacing-vertical) var(--nav-element-spacing-horizontal)}nav li>*{--spacing:0}nav :where(a,[role=link]){display:inline-block;margin:calc(var(--nav-link-spacing-vertical) * -1) calc(var(--nav-link-spacing-horizontal) * -1);padding:var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal);border-radius:var(--border-radius);text-decoration:none}nav :where(a,[role=link]):is([aria-current],:hover,:active,:focus){text-decoration:none}nav [role=button]{margin-right:inherit;margin-left:inherit;padding:var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal)}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--nav-element-spacing-vertical) * .5) var(--nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--spacing) * .5);overflow:hidden;border:0;border-radius:var(--border-radius);background-color:var(--progress-background-color);color:var(--progress-color)}progress::-webkit-progress-bar{border-radius:var(--border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--progress-color)}progress::-moz-progress-bar{background-color:var(--progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--progress-background-color) linear-gradient(to right,var(--progress-color) 30%,var(--progress-background-color) 30%) top left/150% 150% no-repeat;-webkit-animation:progressIndeterminate 1s linear infinite;animation:progressIndeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@-webkit-keyframes progressIndeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}@keyframes progressIndeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}details[role=list],li[role=list]{position:relative}details[role=list] summary+ul,li[role=list]>ul{display:flex;z-index:99;position:absolute;top:auto;right:0;left:0;flex-direction:column;margin:0;padding:0;border:var(--border-width) solid var(--dropdown-border-color);border-radius:var(--border-radius);border-top-right-radius:0;border-top-left-radius:0;background-color:var(--dropdown-background-color);box-shadow:var(--card-box-shadow);color:var(--dropdown-color);white-space:nowrap}details[role=list] summary+ul li,li[role=list]>ul li{width:100%;margin-bottom:0;padding:calc(var(--form-element-spacing-vertical) * .5) var(--form-element-spacing-horizontal);list-style:none}details[role=list] summary+ul li:first-of-type,li[role=list]>ul li:first-of-type{margin-top:calc(var(--form-element-spacing-vertical) * .5)}details[role=list] summary+ul li:last-of-type,li[role=list]>ul li:last-of-type{margin-bottom:calc(var(--form-element-spacing-vertical) * .5)}details[role=list] summary+ul li a,li[role=list]>ul li a{display:block;margin:calc(var(--form-element-spacing-vertical) * -.5) calc(var(--form-element-spacing-horizontal) * -1);padding:calc(var(--form-element-spacing-vertical) * .5) var(--form-element-spacing-horizontal);overflow:hidden;color:var(--dropdown-color);text-decoration:none;text-overflow:ellipsis}details[role=list] summary+ul li a:hover,li[role=list]>ul li a:hover{background-color:var(--dropdown-hover-background-color)}details[role=list] summary::after,li[role=list]>a::after{display:block;width:1rem;height:calc(1rem * var(--line-height,1.5));-webkit-margin-start:0.5rem;margin-inline-start:.5rem;float:right;transform:rotate(0);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}details[role=list]{padding:0;border-bottom:none}details[role=list] summary{margin-bottom:0}details[role=list] summary:not([role]){height:calc(1rem * var(--line-height) + var(--form-element-spacing-vertical) * 2 + var(--border-width) * 2);padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal);border:var(--border-width) solid var(--form-element-border-color);border-radius:var(--border-radius);background-color:var(--form-element-background-color);color:var(--form-element-placeholder-color);line-height:inherit;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}details[role=list] summary:not([role]):active,details[role=list] summary:not([role]):focus{border-color:var(--form-element-active-border-color);background-color:var(--form-element-active-background-color)}details[role=list] summary:not([role]):focus{box-shadow:0 0 0 var(--outline-width) var(--form-element-focus-color)}details[role=list][open] summary{border-bottom-right-radius:0;border-bottom-left-radius:0}details[role=list][open] summary::before{display:block;z-index:1;position:fixed;top:0;right:0;bottom:0;left:0;background:0 0;content:"";cursor:default}nav details[role=list] summary,nav li[role=list] a{display:flex;direction:ltr}nav details[role=list] summary+ul,nav li[role=list]>ul{min-width:-webkit-fit-content;min-width:-moz-fit-content;min-width:fit-content;border-radius:var(--border-radius)}nav details[role=list] summary+ul li a,nav li[role=list]>ul li a{border-radius:0}nav details[role=list] summary,nav details[role=list] summary:not([role]){height:auto;padding:var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal)}nav details[role=list][open] summary{border-radius:var(--border-radius)}nav details[role=list] summary+ul{margin-top:var(--outline-width);-webkit-margin-start:0;margin-inline-start:0}nav details[role=list] summary[role=link]{margin-bottom:calc(var(--nav-link-spacing-vertical) * -1);line-height:var(--line-height)}nav details[role=list] summary[role=link]+ul{margin-top:calc(var(--nav-link-spacing-vertical) + var(--outline-width));-webkit-margin-start:calc(var(--nav-link-spacing-horizontal) * -1);margin-inline-start:calc(var(--nav-link-spacing-horizontal) * -1)}li[role=list] a:active~ul,li[role=list] a:focus~ul,li[role=list]:hover>ul{display:flex}li[role=list]>ul{display:none;margin-top:calc(var(--nav-link-spacing-vertical) + var(--outline-width));-webkit-margin-start:calc(var(--nav-element-spacing-horizontal) - var(--nav-link-spacing-horizontal));margin-inline-start:calc(var(--nav-element-spacing-horizontal) - var(--nav-link-spacing-horizontal))}li[role=list]>a::after{background-image:var(--icon-chevron)}[aria-busy=true]{cursor:progress}[aria-busy=true]:not(input):not(select):not(textarea)::before{display:inline-block;width:1em;height:1em;border:.1875em solid currentColor;border-radius:1em;border-right-color:transparent;content:"";vertical-align:text-bottom;vertical-align:-.125em;-webkit-animation:spinner .75s linear infinite;animation:spinner .75s linear infinite;opacity:var(--loading-spinner-opacity)}[aria-busy=true]:not(input):not(select):not(textarea):not(:empty)::before{margin-right:calc(var(--spacing) * .5);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing) * .5);margin-inline-end:calc(var(--spacing) * .5)}[aria-busy=true]:not(input):not(select):not(textarea):empty{text-align:center}a[aria-busy=true],button[aria-busy=true],input[type=button][aria-busy=true],input[type=reset][aria-busy=true],input[type=submit][aria-busy=true]{pointer-events:none}@-webkit-keyframes spinner{to{transform:rotate(360deg)}}@keyframes spinner{to{transform:rotate(360deg)}}[data-tooltip]{position:relative}[data-tooltip]:not(a):not(button):not(input){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--border-radius);background:var(--tooltip-background-color);content:attr(data-tooltip);color:var(--tooltip-color);font-style:normal;font-weight:var(--font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--tooltip-background-color)}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{-webkit-animation-duration:.2s;animation-duration:.2s;-webkit-animation-name:slide;animation-name:slide}[data-tooltip]:focus::after,[data-tooltip]:hover::after{-webkit-animation-name:slideCaret;animation-name:slideCaret}}@-webkit-keyframes slide{from{transform:translate(-50%,.75rem);opacity:0}to{transform:translate(-50%,-.25rem);opacity:1}}@keyframes slide{from{transform:translate(-50%,.75rem);opacity:0}to{transform:translate(-50%,-.25rem);opacity:1}}@-webkit-keyframes slideCaret{from{opacity:0}50%{transform:translate(-50%,-.25rem);opacity:0}to{transform:translate(-50%,0);opacity:1}}@keyframes slideCaret{from{opacity:0}50%{transform:translate(-50%,-.25rem);opacity:0}to{transform:translate(-50%,0);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;-webkit-animation-duration:1ms!important;animation-duration:1ms!important;-webkit-animation-delay:-1ms!important;animation-delay:-1ms!important;-webkit-animation-iteration-count:1!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}} -/*# sourceMappingURL=pico.min.css.map */ \ No newline at end of file diff --git a/embed/note/detail.html b/embed/note/detail.html deleted file mode 100644 index 0bf38cf..0000000 --- a/embed/note/detail.html +++ /dev/null @@ -1,47 +0,0 @@ -
    -
    - - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
    -
    -
    - -
    - {{.Content}} -
    - -
    - {{if .Post.ApprovedIncomingWebmentions}} -

    - Webmentions -

    - - {{end}} -
    \ No newline at end of file diff --git a/embed/page/detail.html b/embed/page/detail.html deleted file mode 100644 index c2f94b7..0000000 --- a/embed/page/detail.html +++ /dev/null @@ -1,34 +0,0 @@ -
    -
    -

    {{.Title}}

    - - # - -
    -
    -
    - -
    - {{.Content}} -
    - -
    - {{if .Post.ApprovedIncomingWebmentions}} -

    - Webmentions -

    - - {{end}} -
    \ No newline at end of file diff --git a/embed/photo/detail.html b/embed/photo/detail.html deleted file mode 100644 index b80e40a..0000000 --- a/embed/photo/detail.html +++ /dev/null @@ -1,52 +0,0 @@ -
    -
    -

    {{.Title}}

    - - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
    -
    -
    - - {{ if .Post.Meta.PhotoPath }} - {{.Post.Meta.Description}} - {{ end }} - -
    - {{.Content}} -
    - -
    - {{if .Post.ApprovedIncomingWebmentions}} -

    - Webmentions -

    - - {{end}} -
    \ No newline at end of file diff --git a/embed/post-list-photo.html b/embed/post-list-photo.html deleted file mode 100644 index 61b8f2c..0000000 --- a/embed/post-list-photo.html +++ /dev/null @@ -1,9 +0,0 @@ -
    - {{range .}} -
    - - {{.Meta.Description}} - -
    - {{end}} -
    \ No newline at end of file diff --git a/embed/post-list.html b/embed/post-list.html deleted file mode 100644 index 1d814be..0000000 --- a/embed/post-list.html +++ /dev/null @@ -1,25 +0,0 @@ -
    - {{range .}} -
    -
    - {{ if eq .Meta.Type "note"}} -
    - {{ if .Title }}{{.Title}}{{ else }}#{{.Id}}{{ end }} -
    -

    {{.RenderedContent | noescape}}

    - {{ else }} -

    - {{ if .Title }}{{.Title}}{{ else }}#{{.Id}}{{ end }} -

    - {{ end }} - - Published: - - -
    -
    -
    - {{end}} -
    \ No newline at end of file diff --git a/embed/post.html b/embed/post.html deleted file mode 100644 index b08301d..0000000 --- a/embed/post.html +++ /dev/null @@ -1,71 +0,0 @@ -
    -
    -

    {{.Title}}

    - - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
    -
    -
    - - {{ if .Post.Meta.Reply.Url }} -

    - In reply to: - {{ if .Post.Meta.Reply.Text }} - {{.Post.Meta.Reply.Text}} - {{ else }} - {{.Post.Meta.Reply.Url}} - {{ end }} - -

    - {{ end }} - - {{ if .Post.Meta.Bookmark.Url }} -

    - Bookmark: - {{ if .Post.Meta.Bookmark.Text }} - {{.Post.Meta.Bookmark.Text}} - {{ else }} - {{.Post.Meta.Bookmark.Url}} - {{ end }} - -

    - {{ end }} - -
    - {{.Content}} -
    - -
    - {{if .Post.ApprovedIncomingWebmentions}} -

    - Webmentions -

    - - {{end}} -
    \ No newline at end of file diff --git a/embed/recipe/detail.html b/embed/recipe/detail.html deleted file mode 100644 index 2b73808..0000000 --- a/embed/recipe/detail.html +++ /dev/null @@ -1,78 +0,0 @@ -
    -
    -

    {{.Title}}

    - - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
    -
    -
    - - -
    - - - {{ if .Post.Meta.Recipe.Yield }} - Servings: {{ .Post.Meta.Recipe.Yield }} - {{ if .Post.Meta.Recipe.Duration }}, {{end}} - - {{ end }} - - {{ if .Post.Meta.Recipe.Duration }} - Prep Time: - {{ end }} - - -

    Ingredients

    - -
      - {{ range $ingredient := .Post.Meta.Recipe.Ingredients }} -
    • - {{ $ingredient }} -
    • - {{ end }} -
    - -

    Instructions

    - -
    - {{.Content}} -
    -
    - -
    - {{if .Post.ApprovedIncomingWebmentions}} -

    - Webmentions -

    - - {{end}} -
    \ No newline at end of file diff --git a/embed/reply/detail.html b/embed/reply/detail.html deleted file mode 100644 index c74f6bd..0000000 --- a/embed/reply/detail.html +++ /dev/null @@ -1,60 +0,0 @@ -
    -
    -

    {{.Title}}

    - - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
    -
    -
    - - {{ if .Post.Meta.Reply.Url }} -

    - In reply to: - {{ if .Post.Meta.Reply.Text }} - {{.Post.Meta.Reply.Text}} - {{ else }} - {{.Post.Meta.Reply.Url}} - {{ end }} - -

    - {{ end }} - -
    - {{.Content}} -
    - -
    - {{if .Post.ApprovedIncomingWebmentions}} -

    - Webmentions -

    - - {{end}} -
    \ No newline at end of file diff --git a/embed/untyped/detail.html b/embed/untyped/detail.html deleted file mode 100644 index ece34bc..0000000 --- a/embed/untyped/detail.html +++ /dev/null @@ -1,72 +0,0 @@ -
    -
    -

    {{.Title}}

    - - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
    -
    -
    - - {{ if .Post.Meta.Reply.Url }} -

    - In reply to: - {{ if .Post.Meta.Reply.Text }} - {{.Post.Meta.Reply.Text}} - {{ else }} - {{.Post.Meta.Reply.Url}} - {{ end }} - -

    - {{ end }} - - {{ if .Post.Meta.Bookmark.Url }} -

    - Bookmark: - {{ if .Post.Meta.Bookmark.Text }} - {{.Post.Meta.Bookmark.Text}} - {{ else }} - {{.Post.Meta.Bookmark.Url}} - {{ end }} - -

    - {{ end }} - -
    - {{.Content}} -
    - -
    - {{if .Post.ApprovedIncomingWebmentions}} -

    - Webmentions -

    - - {{end}} -
    \ No newline at end of file diff --git a/embed/user-list.html b/embed/user-list.html deleted file mode 100644 index 13ec082..0000000 --- a/embed/user-list.html +++ /dev/null @@ -1,9 +0,0 @@ -{{range .}} - -{{end}} \ No newline at end of file diff --git a/entry_types/article.go b/entry_types/article.go new file mode 100644 index 0000000..9849a24 --- /dev/null +++ b/entry_types/article.go @@ -0,0 +1,37 @@ +package entrytypes + +import ( + "fmt" + "owl-blogs/domain/model" + "owl-blogs/render" +) + +type Article struct { + model.EntryBase + meta ArticleMetaData +} + +type ArticleMetaData struct { + Title string `owl:"inputType=text"` + Content string `owl:"inputType=text widget=textarea"` +} + +func (e *Article) Title() string { + return e.meta.Title +} + +func (e *Article) Content() model.EntryContent { + str, err := render.RenderTemplateToString("entry/Article", e) + if err != nil { + fmt.Println(err) + } + return model.EntryContent(str) +} + +func (e *Article) MetaData() interface{} { + return &e.meta +} + +func (e *Article) SetMetaData(metaData interface{}) { + e.meta = *metaData.(*ArticleMetaData) +} diff --git a/entry_types/bookmark.go b/entry_types/bookmark.go new file mode 100644 index 0000000..2002bd0 --- /dev/null +++ b/entry_types/bookmark.go @@ -0,0 +1,38 @@ +package entrytypes + +import ( + "fmt" + "owl-blogs/domain/model" + "owl-blogs/render" +) + +type Bookmark struct { + model.EntryBase + meta BookmarkMetaData +} + +type BookmarkMetaData struct { + Title string `owl:"inputType=text"` + Url string `owl:"inputType=text"` + Content string `owl:"inputType=text widget=textarea"` +} + +func (e *Bookmark) Title() string { + return e.meta.Title +} + +func (e *Bookmark) Content() model.EntryContent { + str, err := render.RenderTemplateToString("entry/Bookmark", e) + if err != nil { + fmt.Println(err) + } + return model.EntryContent(str) +} + +func (e *Bookmark) MetaData() interface{} { + return &e.meta +} + +func (e *Bookmark) SetMetaData(metaData interface{}) { + e.meta = *metaData.(*BookmarkMetaData) +} diff --git a/entry_types/image.go b/entry_types/image.go new file mode 100644 index 0000000..bd23e37 --- /dev/null +++ b/entry_types/image.go @@ -0,0 +1,38 @@ +package entrytypes + +import ( + "fmt" + "owl-blogs/domain/model" + "owl-blogs/render" +) + +type Image struct { + model.EntryBase + meta ImageMetaData +} + +type ImageMetaData struct { + ImageId string `owl:"inputType=file"` + Title string `owl:"inputType=text"` + Content string `owl:"inputType=text widget=textarea"` +} + +func (e *Image) Title() string { + return e.meta.Title +} + +func (e *Image) Content() model.EntryContent { + str, err := render.RenderTemplateToString("entry/Image", e) + if err != nil { + fmt.Println(err) + } + return model.EntryContent(str) +} + +func (e *Image) MetaData() interface{} { + return &e.meta +} + +func (e *Image) SetMetaData(metaData interface{}) { + e.meta = *metaData.(*ImageMetaData) +} diff --git a/entry_types/note.go b/entry_types/note.go new file mode 100644 index 0000000..590e9a5 --- /dev/null +++ b/entry_types/note.go @@ -0,0 +1,36 @@ +package entrytypes + +import ( + "fmt" + "owl-blogs/domain/model" + "owl-blogs/render" +) + +type Note struct { + model.EntryBase + meta NoteMetaData +} + +type NoteMetaData struct { + Content string `owl:"inputType=text widget=textarea"` +} + +func (e *Note) Title() string { + return "" +} + +func (e *Note) Content() model.EntryContent { + str, err := render.RenderTemplateToString("entry/Note", e) + if err != nil { + fmt.Println(err) + } + return model.EntryContent(str) +} + +func (e *Note) MetaData() interface{} { + return &e.meta +} + +func (e *Note) SetMetaData(metaData interface{}) { + e.meta = *metaData.(*NoteMetaData) +} diff --git a/entry_types/page.go b/entry_types/page.go new file mode 100644 index 0000000..78faece --- /dev/null +++ b/entry_types/page.go @@ -0,0 +1,37 @@ +package entrytypes + +import ( + "fmt" + "owl-blogs/domain/model" + "owl-blogs/render" +) + +type Page struct { + model.EntryBase + meta PageMetaData +} + +type PageMetaData struct { + Title string `owl:"inputType=text"` + Content string `owl:"inputType=text widget=textarea"` +} + +func (e *Page) Title() string { + return e.meta.Title +} + +func (e *Page) Content() model.EntryContent { + str, err := render.RenderTemplateToString("entry/Page", e) + if err != nil { + fmt.Println(err) + } + return model.EntryContent(str) +} + +func (e *Page) MetaData() interface{} { + return &e.meta +} + +func (e *Page) SetMetaData(metaData interface{}) { + e.meta = *metaData.(*PageMetaData) +} diff --git a/entry_types/recipe.go b/entry_types/recipe.go new file mode 100644 index 0000000..20b0a87 --- /dev/null +++ b/entry_types/recipe.go @@ -0,0 +1,40 @@ +package entrytypes + +import ( + "fmt" + "owl-blogs/domain/model" + "owl-blogs/render" +) + +type Recipe struct { + model.EntryBase + meta RecipeMetaData +} + +type RecipeMetaData struct { + Title string `owl:"inputType=text"` + Yield string `owl:"inputType=text"` + Duration string `owl:"inputType=text"` + Ingredients []string `owl:"inputType=text widget=textarea"` + Content string `owl:"inputType=text widget=textarea"` +} + +func (e *Recipe) Title() string { + return e.meta.Title +} + +func (e *Recipe) Content() model.EntryContent { + str, err := render.RenderTemplateToString("entry/Recipe", e) + if err != nil { + fmt.Println(err) + } + return model.EntryContent(str) +} + +func (e *Recipe) MetaData() interface{} { + return &e.meta +} + +func (e *Recipe) SetMetaData(metaData interface{}) { + e.meta = *metaData.(*RecipeMetaData) +} diff --git a/entry_types/reply.go b/entry_types/reply.go new file mode 100644 index 0000000..de2f751 --- /dev/null +++ b/entry_types/reply.go @@ -0,0 +1,38 @@ +package entrytypes + +import ( + "fmt" + "owl-blogs/domain/model" + "owl-blogs/render" +) + +type Reply struct { + model.EntryBase + meta ReplyMetaData +} + +type ReplyMetaData struct { + Title string `owl:"inputType=text"` + Url string `owl:"inputType=text"` + Content string `owl:"inputType=text widget=textarea"` +} + +func (e *Reply) Title() string { + return "Re: " + e.meta.Title +} + +func (e *Reply) Content() model.EntryContent { + str, err := render.RenderTemplateToString("entry/Reply", e) + if err != nil { + fmt.Println(err) + } + return model.EntryContent(str) +} + +func (e *Reply) MetaData() interface{} { + return &e.meta +} + +func (e *Reply) SetMetaData(metaData interface{}) { + e.meta = *metaData.(*ReplyMetaData) +} diff --git a/files.go b/files.go deleted file mode 100644 index 9acc304..0000000 --- a/files.go +++ /dev/null @@ -1,23 +0,0 @@ -package owl - -import ( - "os" - - "gopkg.in/yaml.v2" -) - -func saveToYaml(path string, data interface{}) error { - bytes, err := yaml.Marshal(data) - if err != nil { - return err - } - return os.WriteFile(path, bytes, 0644) -} - -func loadFromYaml(path string, data interface{}) error { - bytes, err := os.ReadFile(path) - if err != nil { - return err - } - return yaml.Unmarshal(bytes, data) -} diff --git a/fixtures/image.png b/fixtures/image.png deleted file mode 100644 index 538dcf9..0000000 Binary files a/fixtures/image.png and /dev/null differ diff --git a/go.mod b/go.mod index b6e1a3e..8496677 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,35 @@ -module h4kor/owl-blogs +module owl-blogs -go 1.18 +go 1.20 require ( - github.com/julienschmidt/httprouter v1.3.0 - github.com/spf13/cobra v1.5.0 - github.com/yuin/goldmark v1.4.13 - golang.org/x/net v0.1.0 - gopkg.in/yaml.v2 v2.4.0 -) - -require ( - github.com/inconshreveable/mousetrap v1.0.1 // indirect + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gofiber/fiber/v2 v2.47.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jmoiron/sqlx v1.3.5 // indirect + github.com/klauspost/compress v1.16.3 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mattn/go-sqlite3 v1.14.17 // indirect + github.com/philhofer/fwd v1.1.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect + github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect + github.com/spf13/cobra v1.7.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/crypto v0.1.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect + github.com/tinylib/msgp v1.1.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.47.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + github.com/yuin/goldmark v1.5.4 // indirect + golang.org/x/crypto v0.11.0 // indirect + golang.org/x/sys v0.10.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 910f1b6..8656157 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,120 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= -github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/gofiber/fiber/v2 v2.47.0 h1:EN5lHVCc+Pyqh5OEsk8fzRiifgwpbrP0rulQ4iNf3fs= +github.com/gofiber/fiber/v2 v2.47.0/go.mod h1:mbFMVN1lQuzziTkkakgtKKdjfsXSw9BKR5lmcNksUoU= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= +github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= +github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= +github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= -github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= +github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 h1:rmMl4fXJhKMNWl+K+r/fq4FbbKI+Ia2m9hYBLm2h4G4= +github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94/go.mod h1:90zrgN3D/WJsDd1iXHT96alCoN2KJo6/4x1DZC3wZs8= +github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw= +github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= +github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.47.0 h1:y7moDoxYzMooFpT5aHgNgVOQDrS3qlkfiP9mDtGGK9c= +github.com/valyala/fasthttp v1.47.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= -golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= -golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY= -golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= -golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= +github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/html.go b/html.go deleted file mode 100644 index 7a86643..0000000 --- a/html.go +++ /dev/null @@ -1,269 +0,0 @@ -package owl - -import ( - "bytes" - "errors" - "io" - "net/http" - "net/url" - "strings" - - "golang.org/x/net/html" -) - -type HtmlParser interface { - ParseHEntry(resp *http.Response) (ParsedHEntry, error) - ParseLinks(resp *http.Response) ([]string, error) - ParseLinksFromString(string) ([]string, error) - GetWebmentionEndpoint(resp *http.Response) (string, error) - GetRedirctUris(resp *http.Response) ([]string, error) -} - -type OwlHtmlParser struct{} - -type ParsedHEntry struct { - Title string -} - -func collectText(n *html.Node, buf *bytes.Buffer) { - - if n.Type == html.TextNode { - buf.WriteString(n.Data) - } - for c := n.FirstChild; c != nil; c = c.NextSibling { - collectText(c, buf) - } -} - -func readResponseBody(resp *http.Response) (string, error) { - defer resp.Body.Close() - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - return string(bodyBytes), nil -} - -func (OwlHtmlParser) ParseHEntry(resp *http.Response) (ParsedHEntry, error) { - htmlStr, err := readResponseBody(resp) - if err != nil { - return ParsedHEntry{}, err - } - doc, err := html.Parse(strings.NewReader(htmlStr)) - if err != nil { - return ParsedHEntry{}, err - } - - var interpretHFeed func(*html.Node, *ParsedHEntry, bool) (ParsedHEntry, error) - interpretHFeed = func(n *html.Node, curr *ParsedHEntry, parent bool) (ParsedHEntry, error) { - attrs := n.Attr - for _, attr := range attrs { - if attr.Key == "class" && strings.Contains(attr.Val, "p-name") { - buf := &bytes.Buffer{} - collectText(n, buf) - curr.Title = buf.String() - return *curr, nil - } - } - - for c := n.FirstChild; c != nil; c = c.NextSibling { - interpretHFeed(c, curr, false) - } - return *curr, nil - } - - var findHFeed func(*html.Node) (ParsedHEntry, error) - findHFeed = func(n *html.Node) (ParsedHEntry, error) { - attrs := n.Attr - for _, attr := range attrs { - if attr.Key == "class" && strings.Contains(attr.Val, "h-entry") { - return interpretHFeed(n, &ParsedHEntry{}, true) - } - } - for c := n.FirstChild; c != nil; c = c.NextSibling { - entry, err := findHFeed(c) - if err == nil { - return entry, nil - } - } - return ParsedHEntry{}, errors.New("no h-entry found") - } - return findHFeed(doc) -} - -func (OwlHtmlParser) ParseLinks(resp *http.Response) ([]string, error) { - htmlStr, err := readResponseBody(resp) - if err != nil { - return []string{}, err - } - return OwlHtmlParser{}.ParseLinksFromString(htmlStr) -} - -func (OwlHtmlParser) ParseLinksFromString(htmlStr string) ([]string, error) { - doc, err := html.Parse(strings.NewReader(htmlStr)) - if err != nil { - return make([]string, 0), err - } - - var findLinks func(*html.Node) ([]string, error) - findLinks = func(n *html.Node) ([]string, error) { - links := make([]string, 0) - if n.Type == html.ElementNode && n.Data == "a" { - for _, attr := range n.Attr { - if attr.Key == "href" { - links = append(links, attr.Val) - } - } - } - for c := n.FirstChild; c != nil; c = c.NextSibling { - childLinks, _ := findLinks(c) - links = append(links, childLinks...) - } - return links, nil - } - return findLinks(doc) -} - -func (OwlHtmlParser) GetWebmentionEndpoint(resp *http.Response) (string, error) { - //request url - requestUrl := resp.Request.URL - - // Check link headers - for _, linkHeader := range resp.Header["Link"] { - linkHeaderParts := strings.Split(linkHeader, ",") - for _, linkHeaderPart := range linkHeaderParts { - linkHeaderPart = strings.TrimSpace(linkHeaderPart) - params := strings.Split(linkHeaderPart, ";") - if len(params) != 2 { - continue - } - for _, param := range params[1:] { - param = strings.TrimSpace(param) - if strings.Contains(param, "webmention") { - link := strings.Split(params[0], ";")[0] - link = strings.Trim(link, "<>") - linkUrl, err := url.Parse(link) - if err != nil { - return "", err - } - return requestUrl.ResolveReference(linkUrl).String(), nil - } - } - } - } - - htmlStr, err := readResponseBody(resp) - if err != nil { - return "", err - } - doc, err := html.Parse(strings.NewReader(htmlStr)) - if err != nil { - return "", err - } - - var findEndpoint func(*html.Node) (string, error) - findEndpoint = func(n *html.Node) (string, error) { - if n.Type == html.ElementNode && (n.Data == "link" || n.Data == "a") { - for _, attr := range n.Attr { - if attr.Key == "rel" { - vals := strings.Split(attr.Val, " ") - for _, val := range vals { - if val == "webmention" { - for _, attr := range n.Attr { - if attr.Key == "href" { - return attr.Val, nil - } - } - } - } - } - } - } - for c := n.FirstChild; c != nil; c = c.NextSibling { - endpoint, err := findEndpoint(c) - if err == nil { - return endpoint, nil - } - } - return "", errors.New("no webmention endpoint found") - } - linkUrlStr, err := findEndpoint(doc) - if err != nil { - return "", err - } - linkUrl, err := url.Parse(linkUrlStr) - if err != nil { - return "", err - } - return requestUrl.ResolveReference(linkUrl).String(), nil -} - -func (OwlHtmlParser) GetRedirctUris(resp *http.Response) ([]string, error) { - //request url - requestUrl := resp.Request.URL - - htmlStr, err := readResponseBody(resp) - if err != nil { - return make([]string, 0), err - } - doc, err := html.Parse(strings.NewReader(htmlStr)) - if err != nil { - return make([]string, 0), err - } - - var findLinks func(*html.Node) ([]string, error) - // Check link headers - header_links := make([]string, 0) - for _, linkHeader := range resp.Header["Link"] { - linkHeaderParts := strings.Split(linkHeader, ",") - for _, linkHeaderPart := range linkHeaderParts { - linkHeaderPart = strings.TrimSpace(linkHeaderPart) - params := strings.Split(linkHeaderPart, ";") - if len(params) != 2 { - continue - } - for _, param := range params[1:] { - param = strings.TrimSpace(param) - if strings.Contains(param, "redirect_uri") { - link := strings.Split(params[0], ";")[0] - link = strings.Trim(link, "<>") - linkUrl, err := url.Parse(link) - if err == nil { - header_links = append(header_links, requestUrl.ResolveReference(linkUrl).String()) - } - } - } - } - } - - findLinks = func(n *html.Node) ([]string, error) { - links := make([]string, 0) - if n.Type == html.ElementNode && n.Data == "link" { - // check for rel="redirect_uri" - rel := "" - href := "" - - for _, attr := range n.Attr { - if attr.Key == "href" { - href = attr.Val - } - if attr.Key == "rel" { - rel = attr.Val - } - } - if rel == "redirect_uri" { - linkUrl, err := url.Parse(href) - if err == nil { - links = append(links, requestUrl.ResolveReference(linkUrl).String()) - } - } - } - for c := n.FirstChild; c != nil; c = c.NextSibling { - childLinks, _ := findLinks(c) - links = append(links, childLinks...) - } - return links, nil - } - body_links, err := findLinks(doc) - return append(body_links, header_links...), err -} diff --git a/http.go b/http.go deleted file mode 100644 index 7a2f106..0000000 --- a/http.go +++ /dev/null @@ -1,15 +0,0 @@ -package owl - -import ( - "io" - "net/http" - "net/url" -) - -type HttpClient interface { - Get(url string) (resp *http.Response, err error) - Post(url, contentType string, body io.Reader) (resp *http.Response, err error) - PostForm(url string, data url.Values) (resp *http.Response, err error) -} - -type OwlHttpClient = http.Client diff --git a/importer/config.go b/importer/config.go new file mode 100644 index 0000000..7fb861a --- /dev/null +++ b/importer/config.go @@ -0,0 +1,33 @@ +package importer + +type V1UserConfig struct { + Title string `yaml:"title"` + SubTitle string `yaml:"subtitle"` + HeaderColor string `yaml:"header_color"` + AuthorName string `yaml:"author_name"` + Me []V1UserMe `yaml:"me"` + PassworHash string `yaml:"password_hash"` + Lists []V1PostList `yaml:"lists"` + PrimaryListInclude []string `yaml:"primary_list_include"` + HeaderMenu []V1MenuItem `yaml:"header_menu"` + FooterMenu []V1MenuItem `yaml:"footer_menu"` +} + +type V1UserMe struct { + Name string `yaml:"name"` + Url string `yaml:"url"` +} + +type V1PostList struct { + Id string `yaml:"id"` + Title string `yaml:"title"` + Include []string `yaml:"include"` + ListType string `yaml:"list_type"` +} + +type V1MenuItem struct { + Title string `yaml:"title"` + List string `yaml:"list"` + Url string `yaml:"url"` + Post string `yaml:"post"` +} diff --git a/importer/utils.go b/importer/utils.go new file mode 100644 index 0000000..7b77cb3 --- /dev/null +++ b/importer/utils.go @@ -0,0 +1,238 @@ +package importer + +import ( + "bytes" + "os" + "owl-blogs/app" + entrytypes "owl-blogs/entry_types" + "path" + "time" + + "gopkg.in/yaml.v2" +) + +type ReplyData struct { + Url string `yaml:"url"` + Text string `yaml:"text"` +} +type BookmarkData struct { + Url string `yaml:"url"` + Text string `yaml:"text"` +} + +type RecipeData struct { + Yield string `yaml:"yield"` + Duration string `yaml:"duration"` + Ingredients []string `yaml:"ingredients"` +} + +type PostMeta struct { + Type string `yaml:"type"` + Title string `yaml:"title"` + Description string `yaml:"description"` + Aliases []string `yaml:"aliases"` + Date time.Time `yaml:"date"` + Draft bool `yaml:"draft"` + Reply ReplyData `yaml:"reply"` + Bookmark BookmarkData `yaml:"bookmark"` + Recipe RecipeData `yaml:"recipe"` + PhotoPath string `yaml:"photo"` +} + +type Post struct { + Id string + Meta PostMeta + Content string +} + +func (post *Post) MediaDir() string { + return path.Join("public", post.Id, "media") +} + +func (pm *PostMeta) UnmarshalYAML(unmarshal func(interface{}) error) error { + type T struct { + Type string `yaml:"type"` + Title string `yaml:"title"` + Description string `yaml:"description"` + Aliases []string `yaml:"aliases"` + Draft bool `yaml:"draft"` + Reply ReplyData `yaml:"reply"` + Bookmark BookmarkData `yaml:"bookmark"` + Recipe RecipeData `yaml:"recipe"` + PhotoPath string `yaml:"photo"` + } + type S struct { + Date string `yaml:"date"` + } + + var t T + var s S + if err := unmarshal(&t); err != nil { + return err + } + if err := unmarshal(&s); err != nil { + return err + } + + pm.Type = t.Type + if pm.Type == "" { + pm.Type = "article" + } + pm.Title = t.Title + pm.Description = t.Description + pm.Aliases = t.Aliases + pm.Draft = t.Draft + pm.Reply = t.Reply + pm.Bookmark = t.Bookmark + pm.Recipe = t.Recipe + pm.PhotoPath = t.PhotoPath + + possibleFormats := []string{ + "2006-01-02", + time.Layout, + time.ANSIC, + time.UnixDate, + time.RubyDate, + time.RFC822, + time.RFC822Z, + time.RFC850, + time.RFC1123, + time.RFC1123Z, + time.RFC3339, + time.RFC3339Nano, + time.Stamp, + time.StampMilli, + time.StampMicro, + time.StampNano, + } + + for _, format := range possibleFormats { + if t, err := time.Parse(format, s.Date); err == nil { + pm.Date = t + break + } + } + + return nil +} + +func LoadContent(data []byte) string { + + // trim yaml block + // TODO this can be done nicer + trimmedData := bytes.TrimSpace(data) + // ensure that data ends with a newline + trimmedData = append(trimmedData, []byte("\n")...) + // check first line is --- + if string(trimmedData[0:4]) == "---\n" { + trimmedData = trimmedData[4:] + // find --- end + end := bytes.Index(trimmedData, []byte("\n---\n")) + if end != -1 { + data = trimmedData[end+5:] + } + } + + return string(data) +} + +func LoadMeta(data []byte) (PostMeta, error) { + + // get yaml metadata block + meta := PostMeta{} + trimmedData := bytes.TrimSpace(data) + // ensure that data ends with a newline + trimmedData = append(trimmedData, []byte("\n")...) + // check first line is --- + if string(trimmedData[0:4]) == "---\n" { + trimmedData = trimmedData[4:] + // find --- end + end := bytes.Index(trimmedData, []byte("---\n")) + if end != -1 { + metaData := trimmedData[:end] + err := yaml.Unmarshal(metaData, &meta) + if err != nil { + return PostMeta{}, err + } + } + } + + return meta, nil +} + +func AllUserPosts(userPath string) ([]Post, error) { + postFiles := ListDir(path.Join(userPath, "public")) + posts := make([]Post, 0) + for _, id := range postFiles { + // if is a directory and has index.md, add to posts + if dirExists(path.Join(userPath, "public", id)) { + if fileExists(path.Join(userPath, "public", id, "index.md")) { + postData, err := os.ReadFile(path.Join(userPath, "public", id, "index.md")) + if err != nil { + return nil, err + } + meta, err := LoadMeta(postData) + if err != nil { + return nil, err + } + post := Post{ + Id: id, + Content: LoadContent(postData), + Meta: meta, + } + posts = append(posts, post) + } + } + } + + return posts, nil +} + +func ListDir(path string) []string { + dir, _ := os.Open(path) + defer dir.Close() + files, _ := dir.Readdirnames(-1) + return files +} + +func dirExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func ConvertTypeList(v1 []string, registry *app.EntryTypeRegistry) []string { + v2 := make([]string, len(v1)) + for i, v1Type := range v1 { + switch v1Type { + case "article": + name, _ := registry.TypeName(&entrytypes.Article{}) + v2[i] = name + case "bookmark": + name, _ := registry.TypeName(&entrytypes.Bookmark{}) + v2[i] = name + case "reply": + name, _ := registry.TypeName(&entrytypes.Reply{}) + v2[i] = name + case "photo": + name, _ := registry.TypeName(&entrytypes.Image{}) + v2[i] = name + case "note": + name, _ := registry.TypeName(&entrytypes.Note{}) + v2[i] = name + case "recipe": + name, _ := registry.TypeName(&entrytypes.Recipe{}) + v2[i] = name + case "page": + name, _ := registry.TypeName(&entrytypes.Page{}) + v2[i] = name + default: + v2[i] = v1Type + } + } + return v2 +} diff --git a/infra/author_repository.go b/infra/author_repository.go new file mode 100644 index 0000000..0c7bd98 --- /dev/null +++ b/infra/author_repository.go @@ -0,0 +1,61 @@ +package infra + +import ( + "owl-blogs/domain/model" + + "github.com/jmoiron/sqlx" +) + +type sqlAuthor struct { + Name string `db:"name"` + PasswordHash string `db:"password_hash"` +} + +type DefaultAuthorRepo struct { + db *sqlx.DB +} + +func NewDefaultAuthorRepo(db Database) *DefaultAuthorRepo { + sqlxdb := db.Get() + + // Create table if not exists + sqlxdb.MustExec(` + CREATE TABLE IF NOT EXISTS authors ( + name TEXT PRIMARY KEY, + password_hash TEXT NOT NULL + ); + `) + + return &DefaultAuthorRepo{ + db: sqlxdb, + } +} + +// FindByName implements repository.AuthorRepository. +func (r *DefaultAuthorRepo) FindByName(name string) (*model.Author, error) { + var author sqlAuthor + err := r.db.Get(&author, "SELECT * FROM authors WHERE name = ?", name) + if err != nil { + return nil, err + } + return &model.Author{ + Name: author.Name, + PasswordHash: author.PasswordHash, + }, nil +} + +// Create implements repository.AuthorRepository. +func (r *DefaultAuthorRepo) Create(name string, passwordHash string) (*model.Author, error) { + author := sqlAuthor{ + Name: name, + PasswordHash: passwordHash, + } + _, err := r.db.NamedExec("INSERT INTO authors (name, password_hash) VALUES (:name, :password_hash)", author) + if err != nil { + return nil, err + } + return &model.Author{ + Name: author.Name, + PasswordHash: author.PasswordHash, + }, nil +} diff --git a/infra/author_repository_test.go b/infra/author_repository_test.go new file mode 100644 index 0000000..7283ca8 --- /dev/null +++ b/infra/author_repository_test.go @@ -0,0 +1,47 @@ +package infra_test + +import ( + "owl-blogs/app/repository" + "owl-blogs/infra" + "owl-blogs/test" + "testing" + + "github.com/stretchr/testify/require" +) + +func setupAutherRepo() repository.AuthorRepository { + db := test.NewMockDb() + repo := infra.NewDefaultAuthorRepo(db) + return repo +} + +func TestAuthorRepoCreate(t *testing.T) { + repo := setupAutherRepo() + + author, err := repo.Create("name", "password") + require.NoError(t, err) + + author, err = repo.FindByName(author.Name) + require.NoError(t, err) + require.Equal(t, author.Name, "name") + require.Equal(t, author.PasswordHash, "password") +} + +func TestAuthorRepoNoSideEffect(t *testing.T) { + repo := setupAutherRepo() + + author, err := repo.Create("name1", "password1") + require.NoError(t, err) + + author2, err := repo.Create("name2", "password2") + require.NoError(t, err) + + author, err = repo.FindByName(author.Name) + require.NoError(t, err) + author2, err = repo.FindByName(author2.Name) + require.NoError(t, err) + require.Equal(t, author.Name, "name1") + require.Equal(t, author.PasswordHash, "password1") + require.Equal(t, author2.Name, "name2") + require.Equal(t, author2.PasswordHash, "password2") +} diff --git a/infra/binary_file_repository.go b/infra/binary_file_repository.go new file mode 100644 index 0000000..47d29a4 --- /dev/null +++ b/infra/binary_file_repository.go @@ -0,0 +1,101 @@ +package infra + +import ( + "fmt" + "owl-blogs/app/repository" + "owl-blogs/domain/model" + "strings" + + "github.com/jmoiron/sqlx" +) + +type sqlBinaryFile struct { + Id string `db:"id"` + Name string `db:"name"` + EntryId *string `db:"entry_id"` + Data []byte `db:"data"` +} + +type DefaultBinaryFileRepo struct { + db *sqlx.DB +} + +// NewBinaryFileRepo creates a new binary file repository +// It creates the table if not exists +func NewBinaryFileRepo(db Database) repository.BinaryRepository { + sqlxdb := db.Get() + + // Create table if not exists + sqlxdb.MustExec(` + CREATE TABLE IF NOT EXISTS binary_files ( + id VARCHAR(255) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + entry_id VARCHAR(255), + data BLOB NOT NULL + ); + `) + + return &DefaultBinaryFileRepo{db: sqlxdb} +} + +// Create implements repository.BinaryRepository +func (repo *DefaultBinaryFileRepo) Create(name string, data []byte, entry model.Entry) (*model.BinaryFile, error) { + parts := strings.Split(name, ".") + fileName := strings.Join(parts[:len(parts)-1], ".") + fileExt := parts[len(parts)-1] + id := fileName + "." + fileExt + + // check if id exists + var count int + err := repo.db.Get(&count, "SELECT COUNT(*) FROM binary_files WHERE id = ?", id) + if err != nil { + return nil, err + } + + if count > 0 { + counter := 1 + for { + id = fmt.Sprintf("%s-%d.%s", fileName, counter, fileExt) + err := repo.db.Get(&count, "SELECT COUNT(*) FROM binary_files WHERE id = ?", id) + if err != nil { + return nil, err + } + if count == 0 { + break + } + counter++ + } + } + + var entryId *string + if entry != nil { + eId := entry.ID() + entryId = &eId + } + + _, err = repo.db.Exec("INSERT INTO binary_files (id, name, entry_id, data) VALUES (?, ?, ?, ?)", id, name, entryId, data) + if err != nil { + return nil, err + } + return &model.BinaryFile{Id: id, Name: name, Data: data}, nil +} + +// FindById implements repository.BinaryRepository +func (repo *DefaultBinaryFileRepo) FindById(id string) (*model.BinaryFile, error) { + var sqlFile sqlBinaryFile + err := repo.db.Get(&sqlFile, "SELECT * FROM binary_files WHERE id = ?", id) + if err != nil { + return nil, err + } + return &model.BinaryFile{Id: sqlFile.Id, Name: sqlFile.Name, Data: sqlFile.Data}, nil +} + +// FindByNameForEntry implements repository.BinaryRepository +func (repo *DefaultBinaryFileRepo) FindByNameForEntry(name string, entry model.Entry) (*model.BinaryFile, error) { + var sqlFile sqlBinaryFile + err := repo.db.Get(&sqlFile, "SELECT * FROM binary_files WHERE name = ? AND entry_id = ?", name, entry.ID()) + if err != nil { + return nil, err + } + return &model.BinaryFile{Id: sqlFile.Id, Name: sqlFile.Name, Data: sqlFile.Data}, nil +} diff --git a/infra/binary_file_repository_test.go b/infra/binary_file_repository_test.go new file mode 100644 index 0000000..4bd7a82 --- /dev/null +++ b/infra/binary_file_repository_test.go @@ -0,0 +1,59 @@ +package infra_test + +import ( + "owl-blogs/app/repository" + "owl-blogs/infra" + "owl-blogs/test" + "testing" + + "github.com/stretchr/testify/require" +) + +func setupBinaryRepo() repository.BinaryRepository { + db := test.NewMockDb() + repo := infra.NewBinaryFileRepo(db) + return repo +} + +func TestBinaryRepoCreate(t *testing.T) { + repo := setupBinaryRepo() + + file, err := repo.Create("name", []byte("😀 😃 😄 😁"), nil) + require.NoError(t, err) + + file, err = repo.FindById(file.Id) + require.NoError(t, err) + require.Equal(t, file.Name, "name") + require.Equal(t, file.Data, []byte("😀 😃 😄 😁")) +} + +func TestBinaryRepoNoSideEffect(t *testing.T) { + repo := setupBinaryRepo() + + file, err := repo.Create("name1", []byte("111"), nil) + require.NoError(t, err) + + file2, err := repo.Create("name2", []byte("222"), nil) + require.NoError(t, err) + + file, err = repo.FindById(file.Id) + require.NoError(t, err) + file2, err = repo.FindById(file2.Id) + require.NoError(t, err) + require.Equal(t, file.Name, "name1") + require.Equal(t, file.Data, []byte("111")) + require.Equal(t, file2.Name, "name2") + require.Equal(t, file2.Data, []byte("222")) +} + +func TestBinaryWithSpaceInName(t *testing.T) { + repo := setupBinaryRepo() + + file, err := repo.Create("name with space", []byte("111"), nil) + require.NoError(t, err) + + file, err = repo.FindById(file.Id) + require.NoError(t, err) + require.Equal(t, file.Name, "name with space") + require.Equal(t, file.Data, []byte("111")) +} diff --git a/infra/config_repository.go b/infra/config_repository.go new file mode 100644 index 0000000..c9e6e60 --- /dev/null +++ b/infra/config_repository.go @@ -0,0 +1,60 @@ +package infra + +import ( + "encoding/json" + "owl-blogs/app/repository" + + "github.com/jmoiron/sqlx" +) + +type DefaultConfigRepo struct { + db *sqlx.DB +} + +func NewConfigRepo(db Database) repository.ConfigRepository { + sqlxdb := db.Get() + + sqlxdb.MustExec(` + CREATE TABLE IF NOT EXISTS site_config ( + name TEXT PRIMARY KEY, + config TEXT + ); + `) + + return &DefaultConfigRepo{ + db: sqlxdb, + } +} + +// Get implements repository.SiteConfigRepository. +func (r *DefaultConfigRepo) Get(name string, result interface{}) error { + data := []byte{} + err := r.db.Get(&data, "SELECT config FROM site_config WHERE name = ?", name) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return nil + } + return err + } + if len(data) == 0 { + return nil + } + return json.Unmarshal(data, result) +} + +// Update implements repository.SiteConfigRepository. +func (r *DefaultConfigRepo) Update(name string, siteConfig interface{}) error { + jsonData, err := json.Marshal(siteConfig) + if err != nil { + return err + } + res, err := r.db.Exec("UPDATE site_config SET config = ? WHERE name = ?", jsonData, name) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if rows == 0 { + _, err = r.db.Exec("INSERT INTO site_config (name, config) VALUES (?, ?)", name, jsonData) + } + return err +} diff --git a/infra/config_repository_test.go b/infra/config_repository_test.go new file mode 100644 index 0000000..db4e24e --- /dev/null +++ b/infra/config_repository_test.go @@ -0,0 +1,71 @@ +package infra_test + +import ( + "owl-blogs/app/repository" + "owl-blogs/domain/model" + "owl-blogs/infra" + "owl-blogs/test" + "testing" + + "github.com/stretchr/testify/require" +) + +func setupSiteConfigRepo() repository.ConfigRepository { + db := test.NewMockDb() + repo := infra.NewConfigRepo(db) + return repo +} + +func TestSiteConfigRepo(t *testing.T) { + repo := setupSiteConfigRepo() + + config := model.SiteConfig{} + err := repo.Get("test", &config) + require.NoError(t, err) + require.Equal(t, "", config.Title) + require.Equal(t, "", config.SubTitle) + + config.Title = "title" + config.SubTitle = "SubTitle" + + err = repo.Update("test", config) + require.NoError(t, err) + + config2 := model.SiteConfig{} + err = repo.Get("test", &config2) + require.NoError(t, err) + require.Equal(t, "title", config2.Title) + require.Equal(t, "SubTitle", config2.SubTitle) +} + +func TestSiteConfigUpdates(t *testing.T) { + repo := setupSiteConfigRepo() + config := model.SiteConfig{} + err := repo.Get("test", &config) + require.NoError(t, err) + require.Equal(t, "", config.Title) + require.Equal(t, "", config.SubTitle) + + config.Title = "title" + config.SubTitle = "SubTitle" + + err = repo.Update("test", config) + require.NoError(t, err) + config2 := model.SiteConfig{} + err = repo.Get("test", &config2) + require.NoError(t, err) + require.Equal(t, "title", config2.Title) + require.Equal(t, "SubTitle", config2.SubTitle) + + config2.Title = "title2" + config2.SubTitle = "SubTitle2" + + err = repo.Update("test", config2) + require.NoError(t, err) + config3 := model.SiteConfig{} + err = repo.Get("test", &config3) + require.NoError(t, err) + require.Equal(t, "title2", config3.Title) + require.Equal(t, "SubTitle2", config3.SubTitle) + +} diff --git a/infra/entry_repository.go b/infra/entry_repository.go new file mode 100644 index 0000000..3aad5bb --- /dev/null +++ b/infra/entry_repository.go @@ -0,0 +1,158 @@ +package infra + +import ( + "encoding/json" + "errors" + "owl-blogs/app" + "owl-blogs/app/repository" + "owl-blogs/domain/model" + "reflect" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +type sqlEntry struct { + Id string `db:"id"` + Type string `db:"type"` + PublishedAt *time.Time `db:"published_at"` + MetaData *string `db:"meta_data"` + AuthorId string `db:"author_id"` +} + +type DefaultEntryRepo struct { + typeRegistry *app.EntryTypeRegistry + db *sqlx.DB +} + +// Create implements repository.EntryRepository. +func (r *DefaultEntryRepo) Create(entry model.Entry) error { + t, err := r.typeRegistry.TypeName(entry) + if err != nil { + return errors.New("entry type not registered") + } + + var metaDataJson []byte + if entry.MetaData() != nil { + metaDataJson, _ = json.Marshal(entry.MetaData()) + } + + if entry.ID() == "" { + entry.SetID(uuid.New().String()) + } + + _, err = r.db.Exec("INSERT INTO entries (id, type, published_at, author_id, meta_data) VALUES (?, ?, ?, ?, ?)", entry.ID(), t, entry.PublishedAt(), entry.AuthorId(), metaDataJson) + return err +} + +// Delete implements repository.EntryRepository. +func (r *DefaultEntryRepo) Delete(entry model.Entry) error { + _, err := r.db.Exec("DELETE FROM entries WHERE id = ?", entry.ID()) + return err +} + +// FindAll implements repository.EntryRepository. +func (r *DefaultEntryRepo) FindAll(types *[]string) ([]model.Entry, error) { + filterStr := "" + if types != nil { + filters := []string{} + for _, t := range *types { + filters = append(filters, "type = '"+t+"'") + } + filterStr = strings.Join(filters, " OR ") + } + + var entries []sqlEntry + if filterStr != "" { + err := r.db.Select(&entries, "SELECT * FROM entries WHERE "+filterStr) + if err != nil { + return nil, err + } + } else { + err := r.db.Select(&entries, "SELECT * FROM entries") + if err != nil { + return nil, err + } + } + + result := []model.Entry{} + for _, entry := range entries { + e, err := r.sqlEntryToEntry(entry) + if err != nil { + return nil, err + } + result = append(result, e) + } + return result, nil +} + +// FindById implements repository.EntryRepository. +func (r *DefaultEntryRepo) FindById(id string) (model.Entry, error) { + data := sqlEntry{} + err := r.db.Get(&data, "SELECT * FROM entries WHERE id = ?", id) + if err != nil { + return nil, err + } + if data.Id == "" { + return nil, nil + } + return r.sqlEntryToEntry(data) +} + +// Update implements repository.EntryRepository. +func (r *DefaultEntryRepo) Update(entry model.Entry) error { + exEntry, _ := r.FindById(entry.ID()) + if exEntry == nil { + return errors.New("entry not found") + } + + _, err := r.typeRegistry.TypeName(entry) + if err != nil { + return errors.New("entry type not registered") + } + + var metaDataJson []byte + if entry.MetaData() != nil { + metaDataJson, _ = json.Marshal(entry.MetaData()) + } + + _, err = r.db.Exec("UPDATE entries SET published_at = ?, author_id = ?, meta_data = ? WHERE id = ?", entry.PublishedAt(), entry.AuthorId(), metaDataJson, entry.ID()) + return err +} + +func NewEntryRepository(db Database, register *app.EntryTypeRegistry) repository.EntryRepository { + sqlxdb := db.Get() + + // Create tables if not exists + sqlxdb.MustExec(` + CREATE TABLE IF NOT EXISTS entries ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + published_at DATETIME, + author_id TEXT NOT NULL, + meta_data TEXT NOT NULL + ); + `) + + return &DefaultEntryRepo{ + db: sqlxdb, + typeRegistry: register, + } +} + +func (r *DefaultEntryRepo) sqlEntryToEntry(entry sqlEntry) (model.Entry, error) { + e, err := r.typeRegistry.Type(entry.Type) + if err != nil { + return nil, errors.New("entry type not registered") + } + metaData := reflect.New(reflect.TypeOf(e.MetaData()).Elem()).Interface() + json.Unmarshal([]byte(*entry.MetaData), metaData) + e.SetID(entry.Id) + e.SetPublishedAt(entry.PublishedAt) + e.SetMetaData(metaData) + e.SetAuthorId(entry.AuthorId) + return e, nil +} diff --git a/infra/entry_repository_test.go b/infra/entry_repository_test.go new file mode 100644 index 0000000..458cd42 --- /dev/null +++ b/infra/entry_repository_test.go @@ -0,0 +1,192 @@ +package infra_test + +import ( + "owl-blogs/app" + "owl-blogs/app/repository" + "owl-blogs/infra" + "owl-blogs/test" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func setupRepo() repository.EntryRepository { + db := test.NewMockDb() + register := app.NewEntryTypeRegistry() + register.Register(&test.MockEntry{}) + repo := infra.NewEntryRepository(db, register) + return repo +} + +func TestRepoCreate(t *testing.T) { + repo := setupRepo() + + entry := &test.MockEntry{} + now := time.Now() + entry.SetPublishedAt(&now) + entry.SetAuthorId("authorId") + entry.SetMetaData(&test.MockEntryMetaData{ + Str: "str", + Number: 1, + Date: now, + }) + err := repo.Create(entry) + require.NoError(t, err) + + entry2, err := repo.FindById(entry.ID()) + require.NoError(t, err) + require.Equal(t, entry.ID(), entry2.ID()) + require.Equal(t, entry.Content(), entry2.Content()) + require.Equal(t, entry.AuthorId(), entry2.AuthorId()) + require.Equal(t, entry.PublishedAt().Unix(), entry2.PublishedAt().Unix()) + meta := entry.MetaData().(*test.MockEntryMetaData) + meta2 := entry2.MetaData().(*test.MockEntryMetaData) + require.Equal(t, meta.Str, meta2.Str) + require.Equal(t, meta.Number, meta2.Number) + require.Equal(t, meta.Date.Unix(), meta2.Date.Unix()) +} + +func TestRepoDelete(t *testing.T) { + repo := setupRepo() + + entry := &test.MockEntry{} + now := time.Now() + entry.SetPublishedAt(&now) + entry.SetMetaData(&test.MockEntryMetaData{ + Str: "str", + Number: 1, + Date: now, + }) + err := repo.Create(entry) + require.NoError(t, err) + + err = repo.Delete(entry) + require.NoError(t, err) + + _, err = repo.FindById("id") + require.Error(t, err) +} + +func TestRepoFindAll(t *testing.T) { + repo := setupRepo() + + entry := &test.MockEntry{} + now := time.Now() + entry.SetPublishedAt(&now) + entry.SetMetaData(&test.MockEntryMetaData{ + Str: "str", + Number: 1, + Date: now, + }) + err := repo.Create(entry) + + require.NoError(t, err) + + entry2 := &test.MockEntry{} + now2 := time.Now() + entry2.SetPublishedAt(&now2) + entry2.SetMetaData(&test.MockEntryMetaData{ + Str: "str", + Number: 1, + Date: now, + }) + + err = repo.Create(entry2) + require.NoError(t, err) + + entries, err := repo.FindAll(nil) + require.NoError(t, err) + require.Equal(t, 2, len(entries)) + + entries, err = repo.FindAll(&[]string{"MockEntry"}) + require.NoError(t, err) + require.Equal(t, 2, len(entries)) + + entries, err = repo.FindAll(&[]string{"MockEntry2"}) + require.NoError(t, err) + require.Equal(t, 0, len(entries)) + +} + +func TestRepoUpdate(t *testing.T) { + repo := setupRepo() + + entry := &test.MockEntry{} + now := time.Now() + entry.SetPublishedAt(&now) + entry.SetAuthorId("authorId") + entry.SetMetaData(&test.MockEntryMetaData{ + Str: "str", + Number: 1, + Date: now, + }) + err := repo.Create(entry) + require.NoError(t, err) + + entry2 := &test.MockEntry{} + now2 := time.Now() + entry2.SetPublishedAt(&now2) + entry.SetAuthorId("authorId2") + entry2.SetMetaData(&test.MockEntryMetaData{ + Str: "str2", + Number: 2, + Date: now2, + }) + err = repo.Create(entry2) + require.NoError(t, err) + err = repo.Update(entry2) + require.NoError(t, err) + + entry3, err := repo.FindById(entry2.ID()) + require.NoError(t, err) + require.Equal(t, entry3.Content(), entry2.Content()) + require.Equal(t, entry3.PublishedAt().Unix(), entry2.PublishedAt().Unix()) + require.Equal(t, entry3.AuthorId(), entry2.AuthorId()) + meta := entry3.MetaData().(*test.MockEntryMetaData) + meta2 := entry2.MetaData().(*test.MockEntryMetaData) + require.Equal(t, meta.Str, meta2.Str) + require.Equal(t, meta.Number, meta2.Number) + require.Equal(t, meta.Date.Unix(), meta2.Date.Unix()) +} + +func TestRepoNoSideEffect(t *testing.T) { + repo := setupRepo() + + entry1 := &test.MockEntry{} + now1 := time.Now() + entry1.SetPublishedAt(&now1) + entry1.SetMetaData(&test.MockEntryMetaData{ + Str: "1", + Number: 1, + Date: now1, + }) + + err := repo.Create(entry1) + require.NoError(t, err) + + entry2 := &test.MockEntry{} + now2 := time.Now() + entry2.SetPublishedAt(&now2) + entry2.SetMetaData(&test.MockEntryMetaData{ + Str: "2", + Number: 2, + Date: now2, + }) + err = repo.Create(entry2) + require.NoError(t, err) + + r1, err := repo.FindById(entry1.ID()) + require.NoError(t, err) + r2, err := repo.FindById(entry2.ID()) + require.NoError(t, err) + + require.Equal(t, r1.MetaData().(*test.MockEntryMetaData).Str, "1") + require.Equal(t, r1.MetaData().(*test.MockEntryMetaData).Number, 1) + require.Equal(t, r1.MetaData().(*test.MockEntryMetaData).Date.Unix(), now1.Unix()) + + require.Equal(t, r2.MetaData().(*test.MockEntryMetaData).Str, "2") + require.Equal(t, r2.MetaData().(*test.MockEntryMetaData).Number, 2) + require.Equal(t, r2.MetaData().(*test.MockEntryMetaData).Date.Unix(), now2.Unix()) + +} diff --git a/infra/interface.go b/infra/interface.go new file mode 100644 index 0000000..367f929 --- /dev/null +++ b/infra/interface.go @@ -0,0 +1,7 @@ +package infra + +import "github.com/jmoiron/sqlx" + +type Database interface { + Get() *sqlx.DB +} diff --git a/infra/sqlite_db.go b/infra/sqlite_db.go new file mode 100644 index 0000000..a779cff --- /dev/null +++ b/infra/sqlite_db.go @@ -0,0 +1,18 @@ +package infra + +import ( + "github.com/jmoiron/sqlx" +) + +type SqliteDatabase struct { + db *sqlx.DB +} + +func NewSqliteDB(path string) Database { + db := sqlx.MustOpen("sqlite3", path) + return &SqliteDatabase{db: db} +} + +func (d *SqliteDatabase) Get() *sqlx.DB { + return d.db +} diff --git a/owl_test.go b/owl_test.go deleted file mode 100644 index 01c112a..0000000 --- a/owl_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package owl_test - -import ( - "h4kor/owl-blogs" - "math/rand" - "time" -) - -func randomName() string { - rand.Seed(time.Now().UnixNano()) - var letters = []rune("abcdefghijklmnopqrstuvwxyz") - b := make([]rune, 8) - for i := range b { - b[i] = letters[rand.Intn(len(letters))] - } - return string(b) -} - -func testRepoName() string { - return "/tmp/" + randomName() -} - -func randomUserName() string { - return randomName() -} - -func getTestUser() owl.User { - repo, _ := owl.CreateRepository(testRepoName(), owl.RepoConfig{}) - user, _ := repo.CreateUser(randomUserName()) - return user -} - -func getTestRepo(config owl.RepoConfig) owl.Repository { - repo, _ := owl.CreateRepository(testRepoName(), config) - return repo -} - -func contains(s []string, e string) bool { - for _, a := range s { - if a == e { - return true - } - } - return false -} diff --git a/post.go b/post.go deleted file mode 100644 index c759fd7..0000000 --- a/post.go +++ /dev/null @@ -1,478 +0,0 @@ -package owl - -import ( - "bytes" - "errors" - "net/url" - "os" - "path" - "sort" - "sync" - "time" - - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/extension" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer/html" - "gopkg.in/yaml.v2" -) - -type GenericPost struct { - user *User - id string - metaLoaded bool - meta PostMeta - wmLock sync.Mutex -} - -func (post *GenericPost) TemplateDir() string { - return post.Meta().Type -} - -type Post interface { - TemplateDir() string - - // Actual Data - User() *User - Id() string - Title() string - Meta() PostMeta - Content() []byte - RenderedContent() string - Aliases() []string - - // Filesystem - Dir() string - MediaDir() string - ContentFile() string - - // Urls - UrlPath() string - FullUrl() string - UrlMediaPath(filename string) string - - // Webmentions Support - IncomingWebmentions() []WebmentionIn - OutgoingWebmentions() []WebmentionOut - PersistIncomingWebmention(webmention WebmentionIn) error - PersistOutgoingWebmention(webmention *WebmentionOut) error - AddIncomingWebmention(source string) error - EnrichWebmention(webmention WebmentionIn) error - ApprovedIncomingWebmentions() []WebmentionIn - ScanForLinks() error - SendWebmention(webmention WebmentionOut) error -} - -type ReplyData struct { - Url string `yaml:"url"` - Text string `yaml:"text"` -} -type BookmarkData struct { - Url string `yaml:"url"` - Text string `yaml:"text"` -} - -type RecipeData struct { - Yield string `yaml:"yield"` - Duration string `yaml:"duration"` - Ingredients []string `yaml:"ingredients"` -} - -type PostMeta struct { - Type string `yaml:"type"` - Title string `yaml:"title"` - Description string `yaml:"description"` - Aliases []string `yaml:"aliases"` - Date time.Time `yaml:"date"` - Draft bool `yaml:"draft"` - Reply ReplyData `yaml:"reply"` - Bookmark BookmarkData `yaml:"bookmark"` - Recipe RecipeData `yaml:"recipe"` - PhotoPath string `yaml:"photo"` -} - -func (pm PostMeta) FormattedDate() string { - return pm.Date.Format("02-01-2006 15:04:05") -} - -func (pm *PostMeta) UnmarshalYAML(unmarshal func(interface{}) error) error { - type T struct { - Type string `yaml:"type"` - Title string `yaml:"title"` - Description string `yaml:"description"` - Aliases []string `yaml:"aliases"` - Draft bool `yaml:"draft"` - Reply ReplyData `yaml:"reply"` - Bookmark BookmarkData `yaml:"bookmark"` - Recipe RecipeData `yaml:"recipe"` - PhotoPath string `yaml:"photo"` - } - type S struct { - Date string `yaml:"date"` - } - - var t T - var s S - if err := unmarshal(&t); err != nil { - return err - } - if err := unmarshal(&s); err != nil { - return err - } - - pm.Type = t.Type - if pm.Type == "" { - pm.Type = "article" - } - pm.Title = t.Title - pm.Description = t.Description - pm.Aliases = t.Aliases - pm.Draft = t.Draft - pm.Reply = t.Reply - pm.Bookmark = t.Bookmark - pm.Recipe = t.Recipe - pm.PhotoPath = t.PhotoPath - - possibleFormats := []string{ - "2006-01-02", - time.Layout, - time.ANSIC, - time.UnixDate, - time.RubyDate, - time.RFC822, - time.RFC822Z, - time.RFC850, - time.RFC1123, - time.RFC1123Z, - time.RFC3339, - time.RFC3339Nano, - time.Stamp, - time.StampMilli, - time.StampMicro, - time.StampNano, - } - - for _, format := range possibleFormats { - if t, err := time.Parse(format, s.Date); err == nil { - pm.Date = t - break - } - } - - return nil -} - -type PostWebmetions struct { - Incoming []WebmentionIn `ymal:"incoming"` - Outgoing []WebmentionOut `ymal:"outgoing"` -} - -func (post *GenericPost) Id() string { - return post.id -} - -func (post *GenericPost) User() *User { - return post.user -} - -func (post *GenericPost) Dir() string { - return path.Join(post.user.Dir(), "public", post.id) -} - -func (post *GenericPost) IncomingWebmentionsFile() string { - return path.Join(post.Dir(), "incoming_webmentions.yml") -} - -func (post *GenericPost) OutgoingWebmentionsFile() string { - return path.Join(post.Dir(), "outgoing_webmentions.yml") -} - -func (post *GenericPost) MediaDir() string { - return path.Join(post.Dir(), "media") -} - -func (post *GenericPost) UrlPath() string { - return post.user.UrlPath() + "posts/" + post.id + "/" -} - -func (post *GenericPost) FullUrl() string { - return post.user.FullUrl() + "posts/" + post.id + "/" -} - -func (post *GenericPost) UrlMediaPath(filename string) string { - return post.UrlPath() + "media/" + filename -} - -func (post *GenericPost) Title() string { - return post.Meta().Title -} - -func (post *GenericPost) ContentFile() string { - return path.Join(post.Dir(), "index.md") -} - -func (post *GenericPost) Meta() PostMeta { - if !post.metaLoaded { - post.LoadMeta() - } - return post.meta -} - -func (post *GenericPost) Content() []byte { - // read file - data, _ := os.ReadFile(post.ContentFile()) - return data -} - -func (post *GenericPost) RenderedContent() string { - data := post.Content() - - // trim yaml block - // TODO this can be done nicer - trimmedData := bytes.TrimSpace(data) - // ensure that data ends with a newline - trimmedData = append(trimmedData, []byte("\n")...) - // check first line is --- - if string(trimmedData[0:4]) == "---\n" { - trimmedData = trimmedData[4:] - // find --- end - end := bytes.Index(trimmedData, []byte("\n---\n")) - if end != -1 { - data = trimmedData[end+5:] - } - } - - options := goldmark.WithRendererOptions() - if config, _ := post.user.repo.Config(); config.AllowRawHtml { - options = goldmark.WithRendererOptions( - html.WithUnsafe(), - ) - } - - markdown := goldmark.New( - options, - goldmark.WithExtensions( - // meta.Meta, - extension.GFM, - ), - ) - var buf bytes.Buffer - context := parser.NewContext() - if err := markdown.Convert(data, &buf, parser.WithContext(context)); err != nil { - panic(err) - } - - return buf.String() - -} - -func (post *GenericPost) Aliases() []string { - return post.Meta().Aliases -} - -func (post *GenericPost) LoadMeta() error { - data := post.Content() - - // get yaml metadata block - meta := PostMeta{} - trimmedData := bytes.TrimSpace(data) - // ensure that data ends with a newline - trimmedData = append(trimmedData, []byte("\n")...) - // check first line is --- - if string(trimmedData[0:4]) == "---\n" { - trimmedData = trimmedData[4:] - // find --- end - end := bytes.Index(trimmedData, []byte("---\n")) - if end != -1 { - metaData := trimmedData[:end] - err := yaml.Unmarshal(metaData, &meta) - if err != nil { - return err - } - } - } - - post.meta = meta - return nil -} - -func (post *GenericPost) IncomingWebmentions() []WebmentionIn { - // return parsed webmentions - fileName := post.IncomingWebmentionsFile() - if !fileExists(fileName) { - return []WebmentionIn{} - } - - webmentions := []WebmentionIn{} - loadFromYaml(fileName, &webmentions) - - return webmentions -} - -func (post *GenericPost) OutgoingWebmentions() []WebmentionOut { - // return parsed webmentions - fileName := post.OutgoingWebmentionsFile() - if !fileExists(fileName) { - return []WebmentionOut{} - } - - webmentions := []WebmentionOut{} - loadFromYaml(fileName, &webmentions) - - return webmentions -} - -// PersistWebmentionOutgoing persists incoming webmention -func (post *GenericPost) PersistIncomingWebmention(webmention WebmentionIn) error { - post.wmLock.Lock() - defer post.wmLock.Unlock() - - wms := post.IncomingWebmentions() - - // if target is not in status, add it - replaced := false - for i, t := range wms { - if t.Source == webmention.Source { - wms[i].UpdateWith(webmention) - replaced = true - break - } - } - - if !replaced { - wms = append(wms, webmention) - } - - err := saveToYaml(post.IncomingWebmentionsFile(), wms) - if err != nil { - return err - } - - return nil -} - -// PersistOutgoingWebmention persists a webmention to the webmention file. -func (post *GenericPost) PersistOutgoingWebmention(webmention *WebmentionOut) error { - post.wmLock.Lock() - defer post.wmLock.Unlock() - - wms := post.OutgoingWebmentions() - - // if target is not in webmention, add it - replaced := false - for i, t := range wms { - if t.Target == webmention.Target { - wms[i].UpdateWith(*webmention) - replaced = true - break - } - } - - if !replaced { - wms = append(wms, *webmention) - } - - err := saveToYaml(post.OutgoingWebmentionsFile(), wms) - if err != nil { - return err - } - - return nil -} - -func (post *GenericPost) AddIncomingWebmention(source string) error { - // Check if file already exists - wm := WebmentionIn{ - Source: source, - } - - defer func() { - go post.EnrichWebmention(wm) - }() - return post.PersistIncomingWebmention(wm) -} - -func (post *GenericPost) EnrichWebmention(webmention WebmentionIn) error { - resp, err := post.user.repo.HttpClient.Get(webmention.Source) - if err == nil { - entry, err := post.user.repo.Parser.ParseHEntry(resp) - if err == nil { - webmention.Title = entry.Title - return post.PersistIncomingWebmention(webmention) - } - } - return err -} - -func (post *GenericPost) ApprovedIncomingWebmentions() []WebmentionIn { - webmentions := post.IncomingWebmentions() - approved := []WebmentionIn{} - for _, webmention := range webmentions { - if webmention.ApprovalStatus == "approved" { - approved = append(approved, webmention) - } - } - - // sort by retrieved date - sort.Slice(approved, func(i, j int) bool { - return approved[i].RetrievedAt.After(approved[j].RetrievedAt) - }) - return approved -} - -// ScanForLinks scans the post content for links and adds them to the -// `status.yml` file for the post. The links are not scanned by this function. -func (post *GenericPost) ScanForLinks() error { - // this could be done in markdown parsing, but I don't want to - // rely on goldmark for this (yet) - postHtml := post.RenderedContent() - links, _ := post.user.repo.Parser.ParseLinksFromString(postHtml) - // add reply url if set - if post.Meta().Reply.Url != "" { - links = append(links, post.Meta().Reply.Url) - } - for _, link := range links { - post.PersistOutgoingWebmention(&WebmentionOut{ - Target: link, - }) - } - return nil -} - -func (post *GenericPost) SendWebmention(webmention WebmentionOut) error { - defer post.PersistOutgoingWebmention(&webmention) - - // if last scan is less than 7 days ago, don't send webmention - if webmention.ScannedAt.After(time.Now().Add(-7*24*time.Hour)) && !webmention.Supported { - return errors.New("did not scan. Last scan was less than 7 days ago") - } - - webmention.ScannedAt = time.Now() - - resp, err := post.user.repo.HttpClient.Get(webmention.Target) - if err != nil { - webmention.Supported = false - return err - } - - endpoint, err := post.user.repo.Parser.GetWebmentionEndpoint(resp) - if err != nil { - webmention.Supported = false - return err - } - webmention.Supported = true - - // send webmention - payload := url.Values{} - payload.Set("source", post.FullUrl()) - payload.Set("target", webmention.Target) - _, err = post.user.repo.HttpClient.PostForm(endpoint, payload) - - if err != nil { - return err - } - - // update webmention status - webmention.LastSentAt = time.Now() - return nil -} diff --git a/post_test.go b/post_test.go deleted file mode 100644 index 4f5c727..0000000 --- a/post_test.go +++ /dev/null @@ -1,531 +0,0 @@ -package owl_test - -import ( - "h4kor/owl-blogs" - "h4kor/owl-blogs/test/assertions" - "h4kor/owl-blogs/test/mocks" - "os" - "path" - "strconv" - "sync" - "testing" - "time" -) - -func TestCanGetPostTitle(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - result := post.Title() - assertions.AssertEqual(t, result, "testpost") -} - -func TestMediaDir(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - result := post.MediaDir() - assertions.AssertEqual(t, result, path.Join(post.Dir(), "media")) -} - -func TestPostUrlPath(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - expected := "/user/" + user.Name() + "/posts/" + post.Id() + "/" - assertions.AssertEqual(t, post.UrlPath(), expected) -} - -func TestPostFullUrl(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - expected := "http://localhost:8080/user/" + user.Name() + "/posts/" + post.Id() + "/" - assertions.AssertEqual(t, post.FullUrl(), expected) -} - -func TestPostUrlMediaPath(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - expected := "/user/" + user.Name() + "/posts/" + post.Id() + "/media/data.png" - assertions.AssertEqual(t, post.UrlMediaPath("data.png"), expected) -} - -func TestPostUrlMediaPathWithSubDir(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - expected := "/user/" + user.Name() + "/posts/" + post.Id() + "/media/foo/data.png" - assertions.AssertEqual(t, post.UrlMediaPath("foo/data.png"), expected) -} - -func TestDraftInMetaData(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - content := "---\n" - content += "title: test\n" - content += "draft: true\n" - content += "---\n" - content += "\n" - content += "Write your post here.\n" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - meta := post.Meta() - assertions.AssertEqual(t, meta.Draft, true) -} - -func TestNoRawHTMLIfDisallowedByRepo(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - content := "---\n" - content += "title: test\n" - content += "draft: true\n" - content += "---\n" - content += "\n" - content += "\n" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - html := post.RenderedContent() - assertions.AssertNotContains(t, html, "\n" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - html := post.RenderedContent() - assertions.AssertContains(t, html, "\n" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - assertions.AssertEqual(t, post.Meta().Title, "test") - assertions.AssertLen(t, post.Meta().Aliases, 1) - assertions.AssertEqual(t, post.Meta().Draft, true) - assertions.AssertEqual(t, post.Meta().Date.Format(time.RFC1123Z), "Wed, 17 Aug 2022 10:50:02 +0000") - assertions.AssertEqual(t, post.Meta().Draft, true) -} - -/// -/// Webmention -/// - -func TestPersistIncomingWebmention(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - webmention := owl.WebmentionIn{ - Source: "http://example.com/source", - } - err := post.PersistIncomingWebmention(webmention) - assertions.AssertNoError(t, err, "Error persisting webmention") - mentions := post.IncomingWebmentions() - assertions.AssertLen(t, mentions, 1) - assertions.AssertEqual(t, mentions[0].Source, webmention.Source) -} - -func TestAddIncomingWebmentionCreatesFile(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - repo.HttpClient = &mocks.MockHttpClient{} - repo.Parser = &mocks.MockHtmlParser{} - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - err := post.AddIncomingWebmention("https://example.com") - assertions.AssertNoError(t, err, "Error adding webmention") - - mentions := post.IncomingWebmentions() - assertions.AssertLen(t, mentions, 1) -} - -func TestAddIncomingWebmentionNotOverwritingWebmention(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - repo.HttpClient = &mocks.MockHttpClient{} - repo.Parser = &mocks.MockHtmlParser{} - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - post.PersistIncomingWebmention(owl.WebmentionIn{ - Source: "https://example.com", - ApprovalStatus: "approved", - }) - - post.AddIncomingWebmention("https://example.com") - - mentions := post.IncomingWebmentions() - assertions.AssertLen(t, mentions, 1) - - assertions.AssertEqual(t, mentions[0].ApprovalStatus, "approved") -} - -func TestEnrichAddsTitle(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - repo.HttpClient = &mocks.MockHttpClient{} - repo.Parser = &mocks.MockHtmlParser{} - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - post.AddIncomingWebmention("https://example.com") - post.EnrichWebmention(owl.WebmentionIn{Source: "https://example.com"}) - - mentions := post.IncomingWebmentions() - assertions.AssertLen(t, mentions, 1) - assertions.AssertEqual(t, mentions[0].Title, "Mock Title") -} - -func TestApprovedIncomingWebmentions(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - webmention := owl.WebmentionIn{ - Source: "http://example.com/source", - ApprovalStatus: "approved", - RetrievedAt: time.Now(), - } - post.PersistIncomingWebmention(webmention) - webmention = owl.WebmentionIn{ - Source: "http://example.com/source2", - ApprovalStatus: "", - RetrievedAt: time.Now().Add(time.Hour * -1), - } - post.PersistIncomingWebmention(webmention) - webmention = owl.WebmentionIn{ - Source: "http://example.com/source3", - ApprovalStatus: "approved", - RetrievedAt: time.Now().Add(time.Hour * -2), - } - post.PersistIncomingWebmention(webmention) - webmention = owl.WebmentionIn{ - Source: "http://example.com/source4", - ApprovalStatus: "rejected", - RetrievedAt: time.Now().Add(time.Hour * -3), - } - post.PersistIncomingWebmention(webmention) - - webmentions := post.ApprovedIncomingWebmentions() - assertions.AssertLen(t, webmentions, 2) - - assertions.AssertEqual(t, webmentions[0].Source, "http://example.com/source") - assertions.AssertEqual(t, webmentions[1].Source, "http://example.com/source3") - -} - -func TestScanningForLinks(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - content := "---\n" - content += "title: test\n" - content += "date: Wed, 17 Aug 2022 10:50:02 +0000\n" - content += "---\n" - content += "\n" - content += "[Hello](https://example.com/hello)\n" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - post.ScanForLinks() - webmentions := post.OutgoingWebmentions() - assertions.AssertLen(t, webmentions, 1) - assertions.AssertEqual(t, webmentions[0].Target, "https://example.com/hello") -} - -func TestScanningForLinksDoesNotAddDuplicates(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - content := "---\n" - content += "title: test\n" - content += "date: Wed, 17 Aug 2022 10:50:02 +0000\n" - content += "---\n" - content += "\n" - content += "[Hello](https://example.com/hello)\n" - content += "[Hello](https://example.com/hello)\n" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - post.ScanForLinks() - post.ScanForLinks() - post.ScanForLinks() - webmentions := post.OutgoingWebmentions() - assertions.AssertLen(t, webmentions, 1) - assertions.AssertEqual(t, webmentions[0].Target, "https://example.com/hello") -} - -func TestScanningForLinksDoesAddReplyUrl(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - content := "---\n" - content += "title: test\n" - content += "date: Wed, 17 Aug 2022 10:50:02 +0000\n" - content += "reply:\n" - content += " url: https://example.com/reply\n" - content += "---\n" - content += "\n" - content += "Hi\n" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - post.ScanForLinks() - webmentions := post.OutgoingWebmentions() - assertions.AssertLen(t, webmentions, 1) - assertions.AssertEqual(t, webmentions[0].Target, "https://example.com/reply") -} - -func TestCanSendWebmention(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - repo.HttpClient = &mocks.MockHttpClient{} - repo.Parser = &mocks.MockHtmlParser{} - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - webmention := owl.WebmentionOut{ - Target: "http://example.com", - } - - err := post.SendWebmention(webmention) - assertions.AssertNoError(t, err, "Error sending webmention") - - webmentions := post.OutgoingWebmentions() - - assertions.AssertLen(t, webmentions, 1) - assertions.AssertEqual(t, webmentions[0].Target, "http://example.com") - assertions.AssertEqual(t, webmentions[0].LastSentAt.IsZero(), false) -} - -func TestSendWebmentionOnlyScansOncePerWeek(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - repo.HttpClient = &mocks.MockHttpClient{} - repo.Parser = &mocks.MockHtmlParser{} - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - webmention := owl.WebmentionOut{ - Target: "http://example.com", - ScannedAt: time.Now().Add(time.Hour * -24 * 6), - } - - post.PersistOutgoingWebmention(&webmention) - webmentions := post.OutgoingWebmentions() - webmention = webmentions[0] - - err := post.SendWebmention(webmention) - assertions.AssertError(t, err, "Expected error, got nil") - - webmentions = post.OutgoingWebmentions() - - assertions.AssertLen(t, webmentions, 1) - assertions.AssertEqual(t, webmentions[0].ScannedAt, webmention.ScannedAt) -} - -func TestSendingMultipleWebmentions(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - repo.HttpClient = &mocks.MockHttpClient{} - repo.Parser = &mocks.MockHtmlParser{} - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - wg := sync.WaitGroup{} - wg.Add(20) - - for i := 0; i < 20; i++ { - go func(k int) { - webmention := owl.WebmentionOut{ - Target: "http://example.com" + strconv.Itoa(k), - } - post.SendWebmention(webmention) - wg.Done() - }(i) - } - - wg.Wait() - - webmentions := post.OutgoingWebmentions() - - assertions.AssertLen(t, webmentions, 20) -} - -func TestReceivingMultipleWebmentions(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - repo.HttpClient = &mocks.MockHttpClient{} - repo.Parser = &mocks.MockHtmlParser{} - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - wg := sync.WaitGroup{} - wg.Add(20) - - for i := 0; i < 20; i++ { - go func(k int) { - post.AddIncomingWebmention("http://example.com" + strconv.Itoa(k)) - wg.Done() - }(i) - } - - wg.Wait() - - webmentions := post.IncomingWebmentions() - - assertions.AssertLen(t, webmentions, 20) - -} - -func TestSendingAndReceivingMultipleWebmentions(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - repo.HttpClient = &mocks.MockHttpClient{} - repo.Parser = &mocks.MockHtmlParser{} - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - wg := sync.WaitGroup{} - wg.Add(40) - - for i := 0; i < 20; i++ { - go func(k int) { - post.AddIncomingWebmention("http://example.com" + strconv.Itoa(k)) - wg.Done() - }(i) - go func(k int) { - webmention := owl.WebmentionOut{ - Target: "http://example.com" + strconv.Itoa(k), - } - post.SendWebmention(webmention) - wg.Done() - }(i) - } - - wg.Wait() - - ins := post.IncomingWebmentions() - outs := post.OutgoingWebmentions() - - assertions.AssertLen(t, ins, 20) - assertions.AssertLen(t, outs, 20) -} - -func TestComplexParallelWebmentions(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - repo.HttpClient = &mocks.MockHttpClient{} - repo.Parser = &mocks.MockParseLinksHtmlParser{ - Links: []string{ - "http://example.com/1", - "http://example.com/2", - "http://example.com/3", - }, - } - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - wg := sync.WaitGroup{} - wg.Add(60) - - for i := 0; i < 20; i++ { - go func(k int) { - post.AddIncomingWebmention("http://example.com/" + strconv.Itoa(k)) - wg.Done() - }(i) - go func(k int) { - webmention := owl.WebmentionOut{ - Target: "http://example.com/" + strconv.Itoa(k), - } - post.SendWebmention(webmention) - wg.Done() - }(i) - go func() { - post.ScanForLinks() - wg.Done() - }() - } - - wg.Wait() - - ins := post.IncomingWebmentions() - outs := post.OutgoingWebmentions() - - assertions.AssertLen(t, ins, 20) - assertions.AssertLen(t, outs, 20) -} - -func TestPostWithoutContent(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser("testuser") - post, _ := user.CreateNewPost(owl.PostMeta{}, "") - - result := post.RenderedContent() - assertions.AssertEqual(t, result, "") -} - -// func TestComplexParallelSimulatedProcessesWebmentions(t *testing.T) { -// repoName := testRepoName() -// repo, _ := owl.CreateRepository(repoName, owl.RepoConfig{}) -// repo.HttpClient = &mocks.MockHttpClient{} -// repo.Parser = &MockParseLinksHtmlParser{ -// Links: []string{ -// "http://example.com/1", -// "http://example.com/2", -// "http://example.com/3", -// }, -// } -// user, _ := repo.CreateUser("testuser") -// post, _ := user.CreateNewPostFull(owl.PostMeta{Type: "article", Title: "testpost"}, "") - -// wg := sync.WaitGroup{} -// wg.Add(40) - -// for i := 0; i < 20; i++ { -// go func(k int) { -// defer wg.Done() -// fRepo, _ := owl.OpenRepository(repoName) -// fUser, _ := fRepo.GetUser("testuser") -// fPost, err := fUser.GetPost(post.Id()) -// if err != nil { -// t.Error(err) -// return -// } -// fPost.AddIncomingWebmention("http://example.com/" + strconv.Itoa(k)) -// }(i) -// go func(k int) { -// defer wg.Done() -// fRepo, _ := owl.OpenRepository(repoName) -// fUser, _ := fRepo.GetUser("testuser") -// fPost, err := fUser.GetPost(post.Id()) -// if err != nil { -// t.Error(err) -// return -// } -// webmention := owl.WebmentionOut{ -// Target: "http://example.com/" + strconv.Itoa(k), -// } -// fPost.SendWebmention(webmention) -// }(i) -// } - -// wg.Wait() - -// ins := post.IncomingWebmentions() - -// if len(ins) != 20 { -// t.Errorf("Expected 20 webmentions, got %d", len(ins)) -// } - -// outs := post.OutgoingWebmentions() - -// if len(outs) != 20 { -// t.Errorf("Expected 20 webmentions, got %d", len(outs)) -// } -// } diff --git a/render/templates.go b/render/templates.go new file mode 100644 index 0000000..9cc9d55 --- /dev/null +++ b/render/templates.go @@ -0,0 +1,91 @@ +package render + +import ( + "bytes" + "embed" + "io" + "owl-blogs/domain/model" + "text/template" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer/html" +) + +type TemplateData struct { + Data interface{} + SiteConfig model.SiteConfig +} + +//go:embed templates +var templates embed.FS + +var funcMap = template.FuncMap{ + "markdown": func(text string) string { + html, err := RenderMarkdown(text) + if err != nil { + return ">>>could not render markdown<<<" + } + return html + }, +} + +func CreateTemplateWithBase(templateName string) (*template.Template, error) { + + return template.New(templateName).Funcs(funcMap).ParseFS( + templates, + "templates/base.tmpl", + "templates/"+templateName+".tmpl", + ) +} + +func RenderTemplateWithBase(w io.Writer, siteConfig model.SiteConfig, templateName string, data interface{}) error { + + t, err := CreateTemplateWithBase(templateName) + + if err != nil { + return err + } + + err = t.ExecuteTemplate(w, "base", TemplateData{ + Data: data, + SiteConfig: siteConfig, + }) + + return err + +} + +func RenderTemplateToString(templateName string, data interface{}) (string, error) { + tmplStr, _ := templates.ReadFile("templates/" + templateName + ".tmpl") + + t, err := template.New("templates/" + templateName + ".tmpl").Funcs(funcMap).Parse(string(tmplStr)) + + if err != nil { + return "", err + } + + var output bytes.Buffer + + err = t.Execute(&output, data) + return output.String(), err +} + +func RenderMarkdown(mdText string) (string, error) { + markdown := goldmark.New( + goldmark.WithRendererOptions( + html.WithUnsafe(), + ), + goldmark.WithExtensions( + // meta.Meta, + extension.GFM, + ), + ) + var buf bytes.Buffer + context := parser.NewContext() + err := markdown.Convert([]byte(mdText), &buf, parser.WithContext(context)) + + return buf.String(), err + +} diff --git a/render/templates/base.tmpl b/render/templates/base.tmpl new file mode 100644 index 0000000..e85f769 --- /dev/null +++ b/render/templates/base.tmpl @@ -0,0 +1,121 @@ +{{define "base"}} + + + + + + {{template "title" .Data}} - {{ .SiteConfig.Title }} + + + + + + +
    +
    +
    +

    {{ .SiteConfig.Title }}

    +

    {{ .SiteConfig.SubTitle }}

    +
    + +
    + {{ if .SiteConfig.AvatarUrl }} + + {{ end }} +
    + {{ range $me := .SiteConfig.Me }} +
  • {{$me.Name}} +
  • + {{ end }} +
    +
    +
    +
    + +
    +
    +
    + {{template "main" .Data}} +
    + + + +{{end}} \ No newline at end of file diff --git a/render/templates/entry/Article.tmpl b/render/templates/entry/Article.tmpl new file mode 100644 index 0000000..f9e080a --- /dev/null +++ b/render/templates/entry/Article.tmpl @@ -0,0 +1 @@ +{{.MetaData.Content | markdown }} diff --git a/render/templates/entry/Bookmark.tmpl b/render/templates/entry/Bookmark.tmpl new file mode 100644 index 0000000..9b3a887 --- /dev/null +++ b/render/templates/entry/Bookmark.tmpl @@ -0,0 +1,3 @@ +Bookmark: {{.MetaData.Url}} + +{{.MetaData.Content | markdown }} diff --git a/render/templates/entry/Image.tmpl b/render/templates/entry/Image.tmpl new file mode 100644 index 0000000..05f38f5 --- /dev/null +++ b/render/templates/entry/Image.tmpl @@ -0,0 +1,4 @@ + + +{{.MetaData.Content}} + diff --git a/render/templates/entry/Note.tmpl b/render/templates/entry/Note.tmpl new file mode 100644 index 0000000..f9e080a --- /dev/null +++ b/render/templates/entry/Note.tmpl @@ -0,0 +1 @@ +{{.MetaData.Content | markdown }} diff --git a/render/templates/entry/Page.tmpl b/render/templates/entry/Page.tmpl new file mode 100644 index 0000000..f9e080a --- /dev/null +++ b/render/templates/entry/Page.tmpl @@ -0,0 +1 @@ +{{.MetaData.Content | markdown }} diff --git a/render/templates/entry/Reply.tmpl b/render/templates/entry/Reply.tmpl new file mode 100644 index 0000000..dfc5323 --- /dev/null +++ b/render/templates/entry/Reply.tmpl @@ -0,0 +1,3 @@ +Reply to: {{.MetaData.Url}} + +{{.MetaData.Content | markdown }} diff --git a/render/templates/views/editor.tmpl b/render/templates/views/editor.tmpl new file mode 100644 index 0000000..4a9f3fa --- /dev/null +++ b/render/templates/views/editor.tmpl @@ -0,0 +1,11 @@ +{{define "title"}}Editor{{end}} + +{{define "main"}} + +Back +
    +
    + +{{.}} + +{{end}} \ No newline at end of file diff --git a/render/templates/views/editor_list.tmpl b/render/templates/views/editor_list.tmpl new file mode 100644 index 0000000..2555fdc --- /dev/null +++ b/render/templates/views/editor_list.tmpl @@ -0,0 +1,24 @@ +{{define "title"}}Editor List{{end}} + +{{define "main"}} + + + + +

    Editor List

    + +
      +{{range .Types}} +
    • {{.}}
    • +{{end}} + +{{end}} \ No newline at end of file diff --git a/render/templates/views/entry.tmpl b/render/templates/views/entry.tmpl new file mode 100644 index 0000000..5d8ab78 --- /dev/null +++ b/render/templates/views/entry.tmpl @@ -0,0 +1,34 @@ +{{define "title"}}{{.Entry.Title}}{{end}} + +{{define "main"}} + +
      +
      + {{if .Entry.Title}} +

      {{.Entry.Title}}

      + {{end}} + + # + Published: + + {{ if .Author.Name }} + by + + {{ if .Author.AvatarUrl }} + + {{ end }} + {{.Author.Name}} + + {{ end }} + +
      + + {{.Entry.Content}} + +
      + + +{{end}} + diff --git a/render/templates/views/index.tmpl b/render/templates/views/index.tmpl new file mode 100644 index 0000000..dc43f01 --- /dev/null +++ b/render/templates/views/index.tmpl @@ -0,0 +1,44 @@ +{{define "title"}}Index{{end}} + +{{define "main"}} + +
      +{{ range .Entries }} +
      +
      +

      + + {{if .Title}} + {{ .Title }} + {{else}} + # + {{end}} + +

      + + + +
      + {{ .Content }} +
      +
      +{{ end }} +
      + +
      + +{{end}} \ No newline at end of file diff --git a/render/templates/views/list.tmpl b/render/templates/views/list.tmpl new file mode 100644 index 0000000..6a65d14 --- /dev/null +++ b/render/templates/views/list.tmpl @@ -0,0 +1,44 @@ +{{define "title"}}Index{{end}} + +{{define "main"}} + +
      +{{ range .Entries }} +
      +
      +

      + + {{if .Title}} + {{ .Title }} + {{else}} + # + {{end}} + +

      + + + +
      + {{ .Content }} +
      +
      +{{ end }} +
      + +
      + +{{end}} \ No newline at end of file diff --git a/render/templates/views/login.tmpl b/render/templates/views/login.tmpl new file mode 100644 index 0000000..b3bd630 --- /dev/null +++ b/render/templates/views/login.tmpl @@ -0,0 +1,16 @@ +{{define "title"}}Editor List{{end}} + +{{define "main"}} + +

      Login

      + +
      + + + + + + + +
      +{{end}} \ No newline at end of file diff --git a/render/templates/views/site_config.tmpl b/render/templates/views/site_config.tmpl new file mode 100644 index 0000000..81da0a9 --- /dev/null +++ b/render/templates/views/site_config.tmpl @@ -0,0 +1,38 @@ +{{define "title"}}Editor{{end}} + +{{define "main"}} + + + +

      Site Settings

      + +
      + + + + + + + + + + + + + + + + +
      + + +{{end}} \ No newline at end of file diff --git a/render/templates/views/site_config_list.tmpl b/render/templates/views/site_config_list.tmpl new file mode 100644 index 0000000..5d65823 --- /dev/null +++ b/render/templates/views/site_config_list.tmpl @@ -0,0 +1,69 @@ +{{define "title"}}Editor{{end}} + +{{define "main"}} + + + +

      Create a List

      +
      + + + + + + + + {{ range .Types}} + + + {{end}} + +
      +
      + + + + +
      + +

      Me Links

      + + + + + + + + + + + + + {{range $i, $l := .Lists}} + + + + + + + + {{end}} + +
      IdTitleIncludeListTypeActions
      {{$l.Id}}{{$l.Title}}{{$l.Include}}{{$l.ListType}} +
      + + +
      +
      + + +{{end}} \ No newline at end of file diff --git a/render/templates/views/site_config_me.tmpl b/render/templates/views/site_config_me.tmpl new file mode 100644 index 0000000..0c5a8d4 --- /dev/null +++ b/render/templates/views/site_config_me.tmpl @@ -0,0 +1,54 @@ +{{define "title"}}Editor{{end}} + +{{define "main"}} + + + +

      Create a Me Link

      +
      + + + + + + + +
      + +

      Me Links

      + + + + + + + + + + + {{range $i, $a := .}} + + + + + + {{end}} + +
      NameURLActions
      {{.Name}}{{.Url}} +
      + + +
      +
      + + +{{end}} \ No newline at end of file diff --git a/render/templates/views/site_config_menus.tmpl b/render/templates/views/site_config_menus.tmpl new file mode 100644 index 0000000..1e1ccef --- /dev/null +++ b/render/templates/views/site_config_menus.tmpl @@ -0,0 +1,106 @@ +{{define "title"}}Editor{{end}} + +{{define "main"}} + + + +

      Create a List

      +
      + + + + + + + + + + + + + + + + + + + +
      + +

      Header Menu

      + + + + + + + + + + + + + {{range $i, $l := .HeaderMenu}} + + + + + + + + {{end}} + +
      TitleListUrlPostActions
      {{$l.Title}}{{$l.List}}{{$l.Url}}{{$l.Post}} +
      + + + +
      +
      + + +

      Footer Menu

      + + + + + + + + + + + + + {{range $i, $l := .FooterMenu}} + + + + + + + + {{end}} + +
      TitleListUrlPostActions
      {{$l.Title}}{{$l.List}}{{$l.Url}}{{$l.Post}} +
      + + + +
      +
      + + +{{end}} \ No newline at end of file diff --git a/renderer.go b/renderer.go deleted file mode 100644 index 3a5ece6..0000000 --- a/renderer.go +++ /dev/null @@ -1,254 +0,0 @@ -package owl - -import ( - "bytes" - _ "embed" - "fmt" - "html/template" - "strings" -) - -type PageContent struct { - Title string - Description string - Content template.HTML - Type string - SelfUrl string -} - -type PostRenderData struct { - Title string - Post Post - Content template.HTML -} - -type AuthRequestData struct { - Me string - ClientId string - RedirectUri string - State string - Scope string - Scopes []string // Split version of scope. filled by rendering function. - ResponseType string - CodeChallenge string - CodeChallengeMethod string - User User - CsrfToken string -} - -type EditorViewData struct { - User User - Error string - CsrfToken string -} - -type ErrorMessage struct { - Error string - Message string -} - -func noescape(str string) template.HTML { - return template.HTML(str) -} - -func listUrl(user User, id string) string { - return user.ListUrl(PostList{ - Id: id, - }) -} - -func postUrl(user User, id string) string { - post, _ := user.GetPost(id) - return post.UrlPath() -} - -func renderEmbedTemplate(templateFile string, data interface{}) (string, error) { - templateStr, err := embed_files.ReadFile(templateFile) - if err != nil { - return "", err - } - return renderTemplateStr(templateStr, data) -} - -func renderTemplateStr(templateStr []byte, data interface{}) (string, error) { - t, err := template.New("_").Funcs(template.FuncMap{ - "noescape": noescape, - "listUrl": listUrl, - "postUrl": postUrl, - }).Parse(string(templateStr)) - if err != nil { - return "", err - } - var html bytes.Buffer - err = t.Execute(&html, data) - if err != nil { - return "", err - } - return html.String(), nil -} - -func renderIntoBaseTemplate(user User, data PageContent) (string, error) { - baseTemplate, _ := user.Template() - t, err := template.New("index").Funcs(template.FuncMap{ - "noescape": noescape, - "listUrl": listUrl, - "postUrl": postUrl, - }).Parse(baseTemplate) - if err != nil { - return "", err - } - - full_data := struct { - Title string - Description string - Content template.HTML - Type string - SelfUrl string - User User - }{ - Title: data.Title, - Description: data.Description, - Content: data.Content, - Type: data.Type, - SelfUrl: data.SelfUrl, - User: user, - } - - var html bytes.Buffer - err = t.Execute(&html, full_data) - return html.String(), err -} - -func renderPostContent(post Post) (string, error) { - buf := post.RenderedContent() - postHtml, err := renderEmbedTemplate( - fmt.Sprintf("embed/%s/detail.html", post.TemplateDir()), - PostRenderData{ - Title: post.Title(), - Post: post, - Content: template.HTML(buf), - }, - ) - return postHtml, err -} - -func RenderPost(post Post) (string, error) { - postHtml, err := renderPostContent(post) - if err != nil { - return "", err - } - - return renderIntoBaseTemplate(*post.User(), PageContent{ - Title: post.Title(), - Description: post.Meta().Description, - Content: template.HTML(postHtml), - Type: "article", - SelfUrl: post.FullUrl(), - }) -} - -func RenderIndexPage(user User) (string, error) { - posts, _ := user.PrimaryFeedPosts() - - postHtml, err := renderEmbedTemplate("embed/post-list.html", posts) - if err != nil { - return "", err - } - - return renderIntoBaseTemplate(user, PageContent{ - Title: "Index", - Content: template.HTML(postHtml), - }) -} - -func RenderPostList(user User, list *PostList) (string, error) { - posts, _ := user.GetPostsOfList(*list) - var postHtml string - var err error - if list.ListType == "photo" { - postHtml, err = renderEmbedTemplate("embed/post-list-photo.html", posts) - } else { - postHtml, err = renderEmbedTemplate("embed/post-list.html", posts) - } - - if err != nil { - return "", err - } - - return renderIntoBaseTemplate(user, PageContent{ - Title: "Index", - Content: template.HTML(postHtml), - }) -} - -func RenderUserAuthPage(reqData AuthRequestData) (string, error) { - reqData.Scopes = strings.Split(reqData.Scope, " ") - authHtml, err := renderEmbedTemplate("embed/auth.html", reqData) - if err != nil { - return "", err - } - - return renderIntoBaseTemplate(reqData.User, PageContent{ - Title: "Auth", - Content: template.HTML(authHtml), - }) -} - -func RenderUserError(user User, error ErrorMessage) (string, error) { - errHtml, err := renderEmbedTemplate("embed/error.html", error) - if err != nil { - return "", err - } - - return renderIntoBaseTemplate(user, PageContent{ - Title: "Error", - Content: template.HTML(errHtml), - }) -} - -func RenderUserList(repo Repository) (string, error) { - baseTemplate, _ := repo.Template() - users, _ := repo.Users() - userHtml, err := renderEmbedTemplate("embed/user-list.html", users) - if err != nil { - return "", err - } - - data := PageContent{ - Title: "Index", - Content: template.HTML(userHtml), - } - - return renderTemplateStr([]byte(baseTemplate), data) -} - -func RenderLoginPage(user User, error_type string, csrfToken string) (string, error) { - loginHtml, err := renderEmbedTemplate("embed/editor/login.html", EditorViewData{ - User: user, - Error: error_type, - CsrfToken: csrfToken, - }) - if err != nil { - return "", err - } - - return renderIntoBaseTemplate(user, PageContent{ - Title: "Login", - Content: template.HTML(loginHtml), - }) -} - -func RenderEditorPage(user User, csrfToken string) (string, error) { - editorHtml, err := renderEmbedTemplate("embed/editor/editor.html", EditorViewData{ - User: user, - CsrfToken: csrfToken, - }) - if err != nil { - return "", err - } - - return renderIntoBaseTemplate(user, PageContent{ - Title: "Editor", - Content: template.HTML(editorHtml), - }) -} diff --git a/renderer_test.go b/renderer_test.go deleted file mode 100644 index 225cea7..0000000 --- a/renderer_test.go +++ /dev/null @@ -1,505 +0,0 @@ -package owl_test - -import ( - "h4kor/owl-blogs" - "h4kor/owl-blogs/test/assertions" - "os" - "path" - "testing" - "time" -) - -func TestCanRenderPost(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - result, err := owl.RenderPost(post) - - assertions.AssertNoError(t, err, "Error rendering post") - assertions.AssertContains(t, result, "testpost") - -} - -func TestRenderOneMe(t *testing.T) { - user := getTestUser() - config := user.Config() - config.Me = append(config.Me, owl.UserMe{ - Name: "Twitter", - Url: "https://twitter.com/testhandle", - }) - - user.SetConfig(config) - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - result, err := owl.RenderPost(post) - - assertions.AssertNoError(t, err, "Error rendering post") - assertions.AssertContains(t, result, "href=\"https://twitter.com/testhandle\" rel=\"me\"") - -} - -func TestRenderTwoMe(t *testing.T) { - user := getTestUser() - config := user.Config() - config.Me = append(config.Me, owl.UserMe{ - Name: "Twitter", - Url: "https://twitter.com/testhandle", - }) - config.Me = append(config.Me, owl.UserMe{ - Name: "Github", - Url: "https://github.com/testhandle", - }) - - user.SetConfig(config) - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - result, err := owl.RenderPost(post) - - assertions.AssertNoError(t, err, "Error rendering post") - assertions.AssertContains(t, result, "href=\"https://twitter.com/testhandle\" rel=\"me\"") - assertions.AssertContains(t, result, "href=\"https://github.com/testhandle\" rel=\"me\"") - -} - -func TestRenderPostHEntry(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - result, _ := owl.RenderPost(post) - assertions.AssertContains(t, result, "class=\"h-entry\"") - assertions.AssertContains(t, result, "class=\"p-name\"") - assertions.AssertContains(t, result, "class=\"e-content\"") - -} - -func TestRendererUsesBaseTemplate(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - result, err := owl.RenderPost(post) - - assertions.AssertNoError(t, err, "Error rendering post") - assertions.AssertContains(t, result, "") -} - -func TestIndexPageContainsHEntryAndUUrl(t *testing.T) { - user := getTestUser() - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost1"}, "") - - result, _ := owl.RenderIndexPage(user) - assertions.AssertContains(t, result, "class=\"h-entry\"") - assertions.AssertContains(t, result, "class=\"u-url\"") - -} - -func TestIndexPageDoesNotContainsArticle(t *testing.T) { - user := getTestUser() - user.CreateNewPost(owl.PostMeta{Type: "article"}, "hi") - - result, _ := owl.RenderIndexPage(user) - assertions.AssertContains(t, result, "class=\"h-entry\"") - assertions.AssertContains(t, result, "class=\"u-url\"") -} - -func TestIndexPageDoesNotContainsReply(t *testing.T) { - user := getTestUser() - user.CreateNewPost(owl.PostMeta{Type: "reply", Reply: owl.ReplyData{Url: "https://example.com/post"}}, "hi") - - result, _ := owl.RenderIndexPage(user) - assertions.AssertContains(t, result, "class=\"h-entry\"") - assertions.AssertContains(t, result, "class=\"u-url\"") -} - -func TestRenderIndexPageWithBrokenBaseTemplate(t *testing.T) { - user := getTestUser() - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost1"}, "") - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost2"}, "") - - os.WriteFile(path.Join(user.Dir(), "meta/base.html"), []byte("{{content}}"), 0644) - - _, err := owl.RenderIndexPage(user) - assertions.AssertError(t, err, "Expected error rendering index page") -} - -func TestRenderUserList(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - repo.CreateUser("user1") - repo.CreateUser("user2") - - result, err := owl.RenderUserList(repo) - assertions.AssertNoError(t, err, "Error rendering user list") - assertions.AssertContains(t, result, "user1") - assertions.AssertContains(t, result, "user2") -} - -func TestRendersHeaderTitle(t *testing.T) { - user := getTestUser() - user.SetConfig(owl.UserConfig{ - Title: "Test Title", - SubTitle: "Test SubTitle", - HeaderColor: "#ff1337", - }) - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - result, _ := owl.RenderPost(post) - assertions.AssertContains(t, result, "Test Title") - assertions.AssertContains(t, result, "Test SubTitle") - assertions.AssertContains(t, result, "#ff1337") -} - -func TestRenderPostIncludesRelToWebMention(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - result, _ := owl.RenderPost(post) - assertions.AssertContains(t, result, "rel=\"webmention\"") - - assertions.AssertContains(t, result, "href=\""+user.WebmentionUrl()+"\"") -} - -func TestRenderPostAddsLinksToApprovedWebmention(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - webmention := owl.WebmentionIn{ - Source: "http://example.com/source3", - Title: "Test Title", - ApprovalStatus: "approved", - RetrievedAt: time.Now().Add(time.Hour * -2), - } - post.PersistIncomingWebmention(webmention) - webmention = owl.WebmentionIn{ - Source: "http://example.com/source4", - ApprovalStatus: "rejected", - RetrievedAt: time.Now().Add(time.Hour * -3), - } - post.PersistIncomingWebmention(webmention) - - result, _ := owl.RenderPost(post) - assertions.AssertContains(t, result, "http://example.com/source3") - assertions.AssertContains(t, result, "Test Title") - assertions.AssertNotContains(t, result, "http://example.com/source4") - -} - -func TestRenderPostNotMentioningWebmentionsIfNoAvail(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - result, _ := owl.RenderPost(post) - - assertions.AssertNotContains(t, result, "Webmention") - -} - -func TestRenderIncludesFullUrl(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - result, _ := owl.RenderPost(post) - - assertions.AssertContains(t, result, "class=\"u-url\"") - assertions.AssertContains(t, result, post.FullUrl()) -} - -func TestAddAvatarIfExist(t *testing.T) { - user := getTestUser() - os.WriteFile(path.Join(user.MediaDir(), "avatar.png"), []byte("test"), 0644) - - result, _ := owl.RenderIndexPage(user) - assertions.AssertContains(t, result, "avatar.png") -} - -func TestAuthorNameInPost(t *testing.T) { - user := getTestUser() - user.SetConfig(owl.UserConfig{ - Title: "Test Title", - SubTitle: "Test SubTitle", - HeaderColor: "#ff1337", - AuthorName: "Test Author", - }) - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - result, _ := owl.RenderPost(post) - assertions.AssertContains(t, result, "Test Author") -} - -func TestRenderReplyWithoutText(t *testing.T) { - - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{ - Type: "reply", - Reply: owl.ReplyData{ - Url: "https://example.com/post", - }, - }, "Hi ") - - result, _ := owl.RenderPost(post) - assertions.AssertContains(t, result, "https://example.com/post") -} - -func TestRenderReplyWithText(t *testing.T) { - - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{ - Type: "reply", - Reply: owl.ReplyData{ - Url: "https://example.com/post", - Text: "This is a reply", - }, - }, "Hi ") - - result, _ := owl.RenderPost(post) - assertions.AssertContains(t, result, "https://example.com/post") - - assertions.AssertContains(t, result, "This is a reply") -} - -func TestRengerPostContainsBookmark(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "bookmark", Bookmark: owl.BookmarkData{Url: "https://example.com/post"}}, "hi") - - result, _ := owl.RenderPost(post) - assertions.AssertContains(t, result, "https://example.com/post") -} - -func TestOpenGraphTags(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - content := "---\n" - content += "title: The Rock\n" - content += "description: Dwayne Johnson\n" - content += "date: Wed, 17 Aug 2022 10:50:02 +0000\n" - content += "---\n" - content += "\n" - content += "Hi \n" - - os.WriteFile(post.ContentFile(), []byte(content), 0644) - post, _ = user.GetPost(post.Id()) - result, _ := owl.RenderPost(post) - - assertions.AssertContains(t, result, "") - assertions.AssertContains(t, result, "") - assertions.AssertContains(t, result, "") - assertions.AssertContains(t, result, "") - -} - -func TestAddFaviconIfExist(t *testing.T) { - user := getTestUser() - os.WriteFile(path.Join(user.MediaDir(), "favicon.png"), []byte("test"), 0644) - - result, _ := owl.RenderIndexPage(user) - assertions.AssertContains(t, result, "favicon.png") -} - -func TestRenderUserAuth(t *testing.T) { - user := getTestUser() - user.ResetPassword("test") - result, err := owl.RenderUserAuthPage(owl.AuthRequestData{ - User: user, - }) - assertions.AssertNoError(t, err, "Error rendering user auth page") - assertions.AssertContains(t, result, " 0, "pico.min.css is empty") -} - -func TestNewRepoGetsBaseHtml(t *testing.T) { - // Create a new user - repo := getTestRepo(owl.RepoConfig{}) - _, err := os.Stat(path.Join(repo.Dir(), "/base.html")) - assertions.AssertNoError(t, err, "Base html file not found") -} - -func TestCanGetRepoTemplate(t *testing.T) { - // Create a new user - repo := getTestRepo(owl.RepoConfig{}) - // Get the user - template, err := repo.Template() - assertions.AssertNoError(t, err, "Error getting template: ") - assertions.Assert(t, template != "", "Template is empty") -} - -func TestCanOpenRepositoryInSingleUserMode(t *testing.T) { - // Create a new user - repoName := testRepoName() - userName := randomUserName() - created_repo, _ := owl.CreateRepository(repoName, owl.RepoConfig{SingleUser: userName}) - created_repo.CreateUser(userName) - created_repo.CreateUser(randomUserName()) - created_repo.CreateUser(randomUserName()) - - // Open the repository - repo, _ := owl.OpenRepository(repoName) - - users, _ := repo.Users() - assertions.AssertLen(t, users, 1) - assertions.Assert(t, users[0].Name() == userName, "User name does not match") -} - -func TestSingleUserRepoUserUrlPathIsSimple(t *testing.T) { - // Create a new user - repoName := testRepoName() - userName := randomUserName() - created_repo, _ := owl.CreateRepository(repoName, owl.RepoConfig{SingleUser: userName}) - created_repo.CreateUser(userName) - - // Open the repository - repo, _ := owl.OpenRepository(repoName) - user, _ := repo.GetUser(userName) - assertions.Assert(t, user.UrlPath() == "/", "User url path is not /") -} - -func TestCanGetMapWithAllPostAliases(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser(randomUserName()) - post, _ := user.CreateNewPost(owl.PostMeta{Title: "test-1"}, "") - - content := "---\n" - content += "title: Test\n" - content += "aliases: \n" - content += " - /foo/bar\n" - content += " - /foo/baz\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - posts, _ := user.PublishedPosts() - assertions.AssertLen(t, posts, 1) - - var aliases map[string]owl.Post - aliases, err := repo.PostAliases() - assertions.AssertNoError(t, err, "Error getting post aliases: ") - assertions.AssertMapLen(t, aliases, 2) - assertions.Assert(t, aliases["/foo/bar"] != nil, "Alias '/foo/bar' not found") - assertions.Assert(t, aliases["/foo/baz"] != nil, "Alias '/foo/baz' not found") - -} - -func TestAliasesHaveCorrectPost(t *testing.T) { - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser(randomUserName()) - post1, _ := user.CreateNewPost(owl.PostMeta{Title: "test-1"}, "") - post2, _ := user.CreateNewPost(owl.PostMeta{Title: "test-2"}, "") - - content := "---\n" - content += "title: Test\n" - content += "aliases: \n" - content += " - /foo/1\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post1.ContentFile(), []byte(content), 0644) - - content = "---\n" - content += "title: Test\n" - content += "aliases: \n" - content += " - /foo/2\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post2.ContentFile(), []byte(content), 0644) - - posts, _ := user.PublishedPosts() - assertions.AssertLen(t, posts, 2) - - var aliases map[string]owl.Post - aliases, err := repo.PostAliases() - assertions.AssertNoError(t, err, "Error getting post aliases: ") - assertions.AssertMapLen(t, aliases, 2) - assertions.Assert(t, aliases["/foo/1"].Id() == post1.Id(), "Alias '/foo/1' does not point to post 1") - assertions.Assert(t, aliases["/foo/2"].Id() == post2.Id(), "Alias '/foo/2' does not point to post 2") - -} diff --git a/rss.go b/rss.go deleted file mode 100644 index efc29d0..0000000 --- a/rss.go +++ /dev/null @@ -1,65 +0,0 @@ -package owl - -import ( - "bytes" - "encoding/xml" - "time" -) - -type RSS struct { - XMLName xml.Name `xml:"rss"` - Version string `xml:"version,attr"` - Channel RSSChannel `xml:"channel"` -} - -type RSSChannel struct { - Title string `xml:"title"` - Link string `xml:"link"` - Description string `xml:"description"` - Items []RSSItem `xml:"item"` -} - -type RSSItem struct { - Guid string `xml:"guid"` - Title string `xml:"title"` - Link string `xml:"link"` - PubDate string `xml:"pubDate"` - Description string `xml:"description"` -} - -func RenderRSSFeed(user User) (string, error) { - - config := user.Config() - - rss := RSS{ - Version: "2.0", - Channel: RSSChannel{ - Title: config.Title, - Link: user.FullUrl(), - Description: config.SubTitle, - Items: make([]RSSItem, 0), - }, - } - - posts, _ := user.PrimaryFeedPosts() - for _, post := range posts { - meta := post.Meta() - content, _ := renderPostContent(post) - rss.Channel.Items = append(rss.Channel.Items, RSSItem{ - Guid: post.FullUrl(), - Title: post.Title(), - Link: post.FullUrl(), - PubDate: meta.Date.Format(time.RFC1123Z), - Description: content, - }) - } - - buf := new(bytes.Buffer) - err := xml.NewEncoder(buf).Encode(rss) - if err != nil { - return "", err - } - - return xml.Header + buf.String(), nil - -} diff --git a/rss_test.go b/rss_test.go deleted file mode 100644 index 7135a4a..0000000 --- a/rss_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package owl_test - -import ( - "h4kor/owl-blogs" - "h4kor/owl-blogs/test/assertions" - "os" - "testing" -) - -func TestRenderRSSFeedMeta(t *testing.T) { - user := getTestUser() - user.SetConfig(owl.UserConfig{ - Title: "Test Title", - SubTitle: "Test SubTitle", - }) - res, err := owl.RenderRSSFeed(user) - assertions.AssertNoError(t, err, "Error rendering RSS feed") - assertions.AssertContains(t, res, "") - assertions.AssertContains(t, res, "") - -} - -func TestRenderRSSFeedUserData(t *testing.T) { - user := getTestUser() - user.SetConfig(owl.UserConfig{ - Title: "Test Title", - SubTitle: "Test SubTitle", - }) - res, err := owl.RenderRSSFeed(user) - assertions.AssertNoError(t, err, "Error rendering RSS feed") - assertions.AssertContains(t, res, "Test Title") - assertions.AssertContains(t, res, "Test SubTitle") - assertions.AssertContains(t, res, "http://localhost:8080/user/") -} - -func TestRenderRSSFeedPostData(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Title: "testpost"}, "") - - content := "---\n" - content += "title: Test Post\n" - content += "date: 2015-01-01\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - res, err := owl.RenderRSSFeed(user) - assertions.AssertNoError(t, err, "Error rendering RSS feed") - assertions.AssertContains(t, res, "Test Post") - assertions.AssertContains(t, res, post.FullUrl()) - assertions.AssertContains(t, res, "Thu, 01 Jan 2015 00:00:00 +0000") -} - -func TestRenderRSSFeedPostDataWithoutDate(t *testing.T) { - user := getTestUser() - post, _ := user.CreateNewPost(owl.PostMeta{Title: "testpost"}, "") - - content := "---\n" - content += "title: Test Post\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - res, err := owl.RenderRSSFeed(user) - assertions.AssertNoError(t, err, "Error rendering RSS feed") - assertions.AssertContains(t, res, "Test Post") - assertions.AssertContains(t, res, post.FullUrl()) -} diff --git a/test/assertions/asserts.go b/test/assertions/asserts.go deleted file mode 100644 index a49d542..0000000 --- a/test/assertions/asserts.go +++ /dev/null @@ -1,109 +0,0 @@ -package assertions - -import ( - "net/http/httptest" - "strings" - "testing" -) - -func Assert(t *testing.T, condition bool, message string) { - t.Helper() - if !condition { - t.Errorf(message) - } -} - -func AssertNot(t *testing.T, condition bool, message string) { - t.Helper() - if condition { - t.Errorf(message) - } -} - -func AssertContains(t *testing.T, containing string, search string) { - t.Helper() - if !strings.Contains(containing, search) { - t.Errorf("Expected '%s' to contain '%s'", containing, search) - } -} - -func AssertArrayContains[T comparable](t *testing.T, list []T, search T) { - t.Helper() - for _, item := range list { - if item == search { - return - } - } - t.Errorf("Expected '%v' to be in '%v'", search, list) -} - -func AssertNotContains(t *testing.T, containing string, search string) { - t.Helper() - if strings.Contains(containing, search) { - t.Errorf("Expected '%s' to not contain '%s'", containing, search) - } -} - -func AssertNoError(t *testing.T, err error, message string) { - t.Helper() - if err != nil { - t.Errorf(message+": %s", err.Error()) - } -} - -func AssertError(t *testing.T, err error, message string) { - t.Helper() - if err == nil { - t.Errorf(message) - } -} - -func AssertLen[T any](t *testing.T, list []T, expected int) { - t.Helper() - if len(list) != expected { - t.Errorf("Expected list to have length %d, got %d", expected, len(list)) - } -} - -func AssertMapLen[T any, S comparable](t *testing.T, list map[S]T, expected int) { - t.Helper() - if len(list) != expected { - t.Errorf("Expected list to have length %d, got %d", expected, len(list)) - } -} - -func AssertEqual[T comparable](t *testing.T, actual T, expected T) { - t.Helper() - if actual != expected { - t.Errorf("Expected '%v', got '%v'", expected, actual) - } -} - -func AssertNotEqual[T comparable](t *testing.T, actual T, expected T) { - t.Helper() - if actual == expected { - t.Errorf("Expected '%v' to not be '%v'", expected, actual) - } -} - -func AssertStatus(t *testing.T, rr *httptest.ResponseRecorder, expStatus int) { - if status := rr.Code; status != expStatus { - t.Errorf("handler returned wrong status code: got %v want %v", - status, expStatus) - return - } -} - -func AssertLessThan(t *testing.T, actual int, expected int) { - t.Helper() - if actual >= expected { - t.Errorf("Expected '%d' to be less than '%d'", actual, expected) - } -} - -func AssertGreaterThan(t *testing.T, actual int, expected int) { - t.Helper() - if actual <= expected { - t.Errorf("Expected '%d' to be greater than '%d'", actual, expected) - } -} diff --git a/test/fixtures/test.png b/test/fixtures/test.png new file mode 100644 index 0000000..fb234fd Binary files /dev/null and b/test/fixtures/test.png differ diff --git a/test/mock_db.go b/test/mock_db.go new file mode 100644 index 0000000..ac1fc74 --- /dev/null +++ b/test/mock_db.go @@ -0,0 +1,19 @@ +package test + +import ( + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +type MockDb struct { + db *sqlx.DB +} + +func (d *MockDb) Get() *sqlx.DB { + return d.db +} + +func NewMockDb() *MockDb { + db := sqlx.MustOpen("sqlite3", ":memory:") + return &MockDb{db: db} +} diff --git a/test/mock_entry.go b/test/mock_entry.go new file mode 100644 index 0000000..6957205 --- /dev/null +++ b/test/mock_entry.go @@ -0,0 +1,33 @@ +package test + +import ( + "owl-blogs/domain/model" + "time" +) + +type MockEntryMetaData struct { + Str string + Number int + Date time.Time +} + +type MockEntry struct { + model.EntryBase + metaData *MockEntryMetaData +} + +func (e *MockEntry) Content() model.EntryContent { + return model.EntryContent(e.metaData.Str) +} + +func (e *MockEntry) MetaData() interface{} { + return e.metaData +} + +func (e *MockEntry) SetMetaData(metaData interface{}) { + e.metaData = metaData.(*MockEntryMetaData) +} + +func (e *MockEntry) Title() string { + return "" +} diff --git a/test/mocks/mocks.go b/test/mocks/mocks.go deleted file mode 100644 index 07ac8e6..0000000 --- a/test/mocks/mocks.go +++ /dev/null @@ -1,64 +0,0 @@ -package mocks - -import ( - "h4kor/owl-blogs" - "io" - "net/http" - "net/url" -) - -type MockHtmlParser struct{} - -func (*MockHtmlParser) ParseHEntry(resp *http.Response) (owl.ParsedHEntry, error) { - return owl.ParsedHEntry{Title: "Mock Title"}, nil - -} -func (*MockHtmlParser) ParseLinks(resp *http.Response) ([]string, error) { - return []string{"http://example.com"}, nil - -} -func (*MockHtmlParser) ParseLinksFromString(string) ([]string, error) { - return []string{"http://example.com"}, nil - -} -func (*MockHtmlParser) GetWebmentionEndpoint(resp *http.Response) (string, error) { - return "http://example.com/webmention", nil - -} -func (*MockHtmlParser) GetRedirctUris(resp *http.Response) ([]string, error) { - return []string{"http://example.com/redirect"}, nil -} - -type MockParseLinksHtmlParser struct { - Links []string -} - -func (*MockParseLinksHtmlParser) ParseHEntry(resp *http.Response) (owl.ParsedHEntry, error) { - return owl.ParsedHEntry{Title: "Mock Title"}, nil -} -func (parser *MockParseLinksHtmlParser) ParseLinks(resp *http.Response) ([]string, error) { - return parser.Links, nil -} -func (parser *MockParseLinksHtmlParser) ParseLinksFromString(string) ([]string, error) { - return parser.Links, nil -} -func (*MockParseLinksHtmlParser) GetWebmentionEndpoint(resp *http.Response) (string, error) { - return "http://example.com/webmention", nil -} -func (parser *MockParseLinksHtmlParser) GetRedirctUris(resp *http.Response) ([]string, error) { - return parser.Links, nil -} - -type MockHttpClient struct{} - -func (*MockHttpClient) Get(url string) (resp *http.Response, err error) { - return &http.Response{}, nil -} -func (*MockHttpClient) Post(url, contentType string, body io.Reader) (resp *http.Response, err error) { - - return &http.Response{}, nil -} -func (*MockHttpClient) PostForm(url string, data url.Values) (resp *http.Response, err error) { - - return &http.Response{}, nil -} diff --git a/user.go b/user.go deleted file mode 100644 index 1ac5974..0000000 --- a/user.go +++ /dev/null @@ -1,547 +0,0 @@ -package owl - -import ( - "crypto/sha256" - "encoding/base64" - "fmt" - "net/url" - "os" - "path" - "sort" - "time" - - "golang.org/x/crypto/bcrypt" - "gopkg.in/yaml.v2" -) - -type User struct { - repo *Repository - name string -} - -type UserConfig struct { - Title string `yaml:"title"` - SubTitle string `yaml:"subtitle"` - HeaderColor string `yaml:"header_color"` - AuthorName string `yaml:"author_name"` - Me []UserMe `yaml:"me"` - PassworHash string `yaml:"password_hash"` - Lists []PostList `yaml:"lists"` - PrimaryListInclude []string `yaml:"primary_list_include"` - HeaderMenu []MenuItem `yaml:"header_menu"` - FooterMenu []MenuItem `yaml:"footer_menu"` -} - -type PostList struct { - Id string `yaml:"id"` - Title string `yaml:"title"` - Include []string `yaml:"include"` - ListType string `yaml:"list_type"` -} - -type MenuItem struct { - Title string `yaml:"title"` - List string `yaml:"list"` - Url string `yaml:"url"` - Post string `yaml:"post"` -} - -func (l *PostList) ContainsType(t string) bool { - for _, t2 := range l.Include { - if t2 == t { - return true - } - } - return false -} - -type UserMe struct { - Name string `yaml:"name"` - Url string `yaml:"url"` -} - -type AuthCode struct { - Code string `yaml:"code"` - ClientId string `yaml:"client_id"` - RedirectUri string `yaml:"redirect_uri"` - CodeChallenge string `yaml:"code_challenge"` - CodeChallengeMethod string `yaml:"code_challenge_method"` - Scope string `yaml:"scope"` - Created time.Time `yaml:"created"` -} - -type AccessToken struct { - Token string `yaml:"token"` - Scope string `yaml:"scope"` - ClientId string `yaml:"client_id"` - RedirectUri string `yaml:"redirect_uri"` - Created time.Time `yaml:"created"` - ExpiresIn int `yaml:"expires_in"` -} - -type Session struct { - Id string `yaml:"id"` - Created time.Time `yaml:"created"` - ExpiresIn int `yaml:"expires_in"` -} - -func (user User) Dir() string { - return path.Join(user.repo.UsersDir(), user.name) -} - -func (user User) UrlPath() string { - return user.repo.UserUrlPath(user) -} - -func (user User) ListUrl(list PostList) string { - url, _ := url.JoinPath(user.UrlPath(), "lists/"+list.Id+"/") - return url -} - -func (user User) FullUrl() string { - url, _ := url.JoinPath(user.repo.FullUrl(), user.UrlPath()) - return url -} - -func (user User) AuthUrl() string { - if user.Config().PassworHash == "" { - return "" - } - url, _ := url.JoinPath(user.FullUrl(), "auth/") - return url -} - -func (user User) TokenUrl() string { - url, _ := url.JoinPath(user.AuthUrl(), "token/") - return url -} - -func (user User) IndieauthMetadataUrl() string { - url, _ := url.JoinPath(user.FullUrl(), ".well-known/oauth-authorization-server") - return url -} - -func (user User) WebmentionUrl() string { - url, _ := url.JoinPath(user.FullUrl(), "webmention/") - return url -} - -func (user User) MicropubUrl() string { - url, _ := url.JoinPath(user.FullUrl(), "micropub/") - return url -} - -func (user User) MediaUrl() string { - url, _ := url.JoinPath(user.UrlPath(), "media") - return url -} - -func (user User) EditorUrl() string { - url, _ := url.JoinPath(user.UrlPath(), "editor/") - return url -} - -func (user User) EditorLoginUrl() string { - url, _ := url.JoinPath(user.UrlPath(), "editor/auth/") - return url -} - -func (user User) PostDir() string { - return path.Join(user.Dir(), "public") -} - -func (user User) MetaDir() string { - return path.Join(user.Dir(), "meta") -} - -func (user User) MediaDir() string { - return path.Join(user.Dir(), "media") -} - -func (user User) ConfigFile() string { - return path.Join(user.MetaDir(), "config.yml") -} - -func (user User) AuthCodesFile() string { - return path.Join(user.MetaDir(), "auth_codes.yml") -} - -func (user User) AccessTokensFile() string { - return path.Join(user.MetaDir(), "access_tokens.yml") -} - -func (user User) SessionsFile() string { - return path.Join(user.MetaDir(), "sessions.yml") -} - -func (user User) Name() string { - return user.name -} - -func (user User) AvatarUrl() string { - for _, ext := range []string{".jpg", ".jpeg", ".png", ".gif"} { - if fileExists(path.Join(user.MediaDir(), "avatar"+ext)) { - url, _ := url.JoinPath(user.MediaUrl(), "avatar"+ext) - return url - } - } - return "" -} - -func (user User) FaviconUrl() string { - for _, ext := range []string{".jpg", ".jpeg", ".png", ".gif", ".ico"} { - if fileExists(path.Join(user.MediaDir(), "favicon"+ext)) { - url, _ := url.JoinPath(user.MediaUrl(), "favicon"+ext) - return url - } - } - return "" -} - -func (user User) AllPosts() ([]Post, error) { - postFiles := listDir(path.Join(user.Dir(), "public")) - posts := make([]Post, 0) - for _, id := range postFiles { - // if is a directory and has index.md, add to posts - if dirExists(path.Join(user.Dir(), "public", id)) { - if fileExists(path.Join(user.Dir(), "public", id, "index.md")) { - post, _ := user.GetPost(id) - posts = append(posts, post) - } - } - } - - type PostWithDate struct { - post Post - date time.Time - } - - postDates := make([]PostWithDate, len(posts)) - for i, post := range posts { - meta := post.Meta() - postDates[i] = PostWithDate{post: post, date: meta.Date} - } - - // sort posts by date - sort.Slice(postDates, func(i, j int) bool { - return postDates[i].date.After(postDates[j].date) - }) - - for i, post := range postDates { - posts[i] = post.post - } - - return posts, nil -} - -func (user User) PublishedPosts() ([]Post, error) { - posts, _ := user.AllPosts() - - // remove drafts - n := 0 - for _, post := range posts { - meta := post.Meta() - if !meta.Draft { - posts[n] = post - n++ - } - } - posts = posts[:n] - return posts, nil -} - -func (user User) PrimaryFeedPosts() ([]Post, error) { - config := user.Config() - include := config.PrimaryListInclude - if len(include) == 0 { - include = []string{"article", "reply"} // default before addition of this option - } - return user.GetPostsOfList(PostList{ - Id: "", - Title: "", - Include: include, - }) -} - -func (user User) GetPostsOfList(list PostList) ([]Post, error) { - posts, _ := user.PublishedPosts() - - // remove posts not included - n := 0 - for _, post := range posts { - meta := post.Meta() - if list.ContainsType(meta.Type) { - posts[n] = post - n++ - } - } - posts = posts[:n] - return posts, nil -} - -func (user User) GetPost(id string) (Post, error) { - // check if posts index.md exists - if !fileExists(path.Join(user.Dir(), "public", id, "index.md")) { - return &GenericPost{}, fmt.Errorf("post %s does not exist", id) - } - - post := GenericPost{user: &user, id: id} - return &post, nil -} - -func (user User) CreateNewPost(meta PostMeta, content string) (Post, error) { - slugHint := meta.Title - if slugHint == "" { - slugHint = "note" - } - folder_name := toDirectoryName(slugHint) - post_dir := path.Join(user.Dir(), "public", folder_name) - - // if post already exists, add -n to the end of the name - i := 0 - for { - if dirExists(post_dir) { - i++ - folder_name = toDirectoryName(fmt.Sprintf("%s-%d", slugHint, i)) - post_dir = path.Join(user.Dir(), "public", folder_name) - } else { - break - } - } - post := GenericPost{user: &user, id: folder_name} - - // if date is not set, set it to now - if meta.Date.IsZero() { - meta.Date = time.Now() - } - - initial_content := "" - initial_content += "---\n" - // write meta - meta_bytes, err := yaml.Marshal(meta) // TODO: this should be down by the Post - if err != nil { - return &GenericPost{}, err - } - initial_content += string(meta_bytes) - initial_content += "---\n" - initial_content += "\n" - initial_content += content - - // create post file - os.Mkdir(post_dir, 0755) - os.WriteFile(post.ContentFile(), []byte(initial_content), 0644) - // create media dir - os.Mkdir(post.MediaDir(), 0755) - return user.GetPost(post.Id()) -} - -func (user User) Template() (string, error) { - // load base.html - path := path.Join(user.Dir(), "meta", "base.html") - base_html, err := os.ReadFile(path) - if err != nil { - return "", err - } - return string(base_html), nil -} - -func (user User) Config() UserConfig { - meta := UserConfig{} - loadFromYaml(user.ConfigFile(), &meta) - return meta -} - -func (user User) SetConfig(new_config UserConfig) error { - return saveToYaml(user.ConfigFile(), new_config) -} - -func (user User) PostAliases() (map[string]Post, error) { - post_aliases := make(map[string]Post) - posts, err := user.PublishedPosts() - if err != nil { - return post_aliases, err - } - for _, post := range posts { - if err != nil { - return post_aliases, err - } - for _, alias := range post.Aliases() { - post_aliases[alias] = post - } - } - return post_aliases, nil -} - -func (user User) GetPostList(id string) (*PostList, error) { - lists := user.Config().Lists - - for _, list := range lists { - if list.Id == id { - return &list, nil - } - } - - return &PostList{}, fmt.Errorf("list %s does not exist", id) -} - -func (user User) AddPostList(list PostList) error { - config := user.Config() - config.Lists = append(config.Lists, list) - return user.SetConfig(config) -} - -func (user User) AddHeaderMenuItem(link MenuItem) error { - config := user.Config() - config.HeaderMenu = append(config.HeaderMenu, link) - return user.SetConfig(config) -} - -func (user User) AddFooterMenuItem(link MenuItem) error { - config := user.Config() - config.FooterMenu = append(config.FooterMenu, link) - return user.SetConfig(config) -} - -func (user User) ResetPassword(password string) error { - bytes, err := bcrypt.GenerateFromPassword([]byte(password), 10) - if err != nil { - return err - } - config := user.Config() - config.PassworHash = string(bytes) - return user.SetConfig(config) -} - -func (user User) VerifyPassword(password string) bool { - err := bcrypt.CompareHashAndPassword( - []byte(user.Config().PassworHash), []byte(password), - ) - return err == nil -} - -func (user User) getAuthCodes() []AuthCode { - codes := make([]AuthCode, 0) - loadFromYaml(user.AuthCodesFile(), &codes) - return codes -} - -func (user User) addAuthCode(code AuthCode) error { - codes := user.getAuthCodes() - codes = append(codes, code) - return saveToYaml(user.AuthCodesFile(), codes) -} - -func (user User) GenerateAuthCode( - client_id string, redirect_uri string, - code_challenge string, code_challenge_method string, - scope string, -) (string, error) { - // generate code - code := GenerateRandomString(32) - return code, user.addAuthCode(AuthCode{ - Code: code, - ClientId: client_id, - RedirectUri: redirect_uri, - CodeChallenge: code_challenge, - CodeChallengeMethod: code_challenge_method, - Scope: scope, - Created: time.Now(), - }) -} - -func (user User) VerifyAuthCode( - code string, client_id string, redirect_uri string, code_verifier string, -) (bool, AuthCode) { - codes := user.getAuthCodes() - for _, c := range codes { - if c.Code == code && c.ClientId == client_id && c.RedirectUri == redirect_uri { - if c.CodeChallengeMethod == "plain" { - return c.CodeChallenge == code_verifier, c - } else if c.CodeChallengeMethod == "S256" { - // hash code_verifier - hash := sha256.Sum256([]byte(code_verifier)) - return c.CodeChallenge == base64.RawURLEncoding.EncodeToString(hash[:]), c - } else if c.CodeChallengeMethod == "" { - // Check age of code - // A maximum lifetime of 10 minutes is recommended ( https://indieauth.spec.indieweb.org/#authorization-response) - if time.Since(c.Created) < 10*time.Minute { - return true, c - } - } - } - } - return false, AuthCode{} -} - -func (user User) getAccessTokens() []AccessToken { - codes := make([]AccessToken, 0) - loadFromYaml(user.AccessTokensFile(), &codes) - return codes -} - -func (user User) addAccessToken(code AccessToken) error { - codes := user.getAccessTokens() - codes = append(codes, code) - return saveToYaml(user.AccessTokensFile(), codes) -} - -func (user User) GenerateAccessToken(authCode AuthCode) (string, int, error) { - // generate code - token := GenerateRandomString(32) - duration := 24 * 60 * 60 - return token, duration, user.addAccessToken(AccessToken{ - Token: token, - ClientId: authCode.ClientId, - RedirectUri: authCode.RedirectUri, - Scope: authCode.Scope, - ExpiresIn: duration, - Created: time.Now(), - }) -} - -func (user User) ValidateAccessToken(token string) (bool, AccessToken) { - tokens := user.getAccessTokens() - for _, t := range tokens { - if t.Token == token { - if time.Since(t.Created) < time.Duration(t.ExpiresIn)*time.Second { - return true, t - } - } - } - return false, AccessToken{} -} - -func (user User) getSessions() []Session { - sessions := make([]Session, 0) - loadFromYaml(user.SessionsFile(), &sessions) - return sessions -} - -func (user User) addSession(session Session) error { - sessions := user.getSessions() - sessions = append(sessions, session) - return saveToYaml(user.SessionsFile(), sessions) -} - -func (user User) CreateNewSession() string { - // generate code - code := GenerateRandomString(32) - user.addSession(Session{ - Id: code, - Created: time.Now(), - ExpiresIn: 30 * 24 * 60 * 60, - }) - return code -} - -func (user User) ValidateSession(session_id string) bool { - sessions := user.getSessions() - for _, session := range sessions { - if session.Id == session_id { - if time.Since(session.Created) < time.Duration(session.ExpiresIn)*time.Second { - return true - } - } - } - return false -} diff --git a/user_test.go b/user_test.go deleted file mode 100644 index bdac4c3..0000000 --- a/user_test.go +++ /dev/null @@ -1,352 +0,0 @@ -package owl_test - -import ( - "fmt" - "h4kor/owl-blogs" - "h4kor/owl-blogs/test/assertions" - "os" - "path" - "testing" -) - -func TestCreateNewPostCreatesEntryInPublic(t *testing.T) { - // Create a new user - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser(randomUserName()) - // Create a new post - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - files, err := os.ReadDir(path.Join(user.Dir(), "public")) - assertions.AssertNoError(t, err, "Error reading directory") - assertions.AssertLen(t, files, 1) -} - -func TestCreateNewPostCreatesMediaDir(t *testing.T) { - // Create a new user - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser(randomUserName()) - // Create a new post - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - _, err := os.Stat(post.MediaDir()) - assertions.AssertNot(t, os.IsNotExist(err), "Media directory not created") -} - -func TestCreateNewPostAddsDateToMetaBlock(t *testing.T) { - user := getTestUser() - // Create a new post - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - posts, _ := user.PublishedPosts() - post, _ := user.GetPost(posts[0].Id()) - meta := post.Meta() - assertions.AssertNot(t, meta.Date.IsZero(), "Date not set") -} - -func TestCreateNewPostMultipleCalls(t *testing.T) { - // Create a new user - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser(randomUserName()) - // Create a new post - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - files, err := os.ReadDir(path.Join(user.Dir(), "public")) - assertions.AssertNoError(t, err, "Error reading directory") - assertions.AssertEqual(t, len(files), 3) -} - -func TestCanListUserPosts(t *testing.T) { - // Create a new user - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser(randomUserName()) - // Create a new post - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - posts, err := user.PublishedPosts() - assertions.AssertNoError(t, err, "Error reading posts") - assertions.AssertLen(t, posts, 3) -} - -func TestCannotListUserPostsInSubdirectories(t *testing.T) { - // Create a new user - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser(randomUserName()) - // Create a new post - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - os.Mkdir(path.Join(user.PostDir(), "foo"), 0755) - os.Mkdir(path.Join(user.PostDir(), "foo/bar"), 0755) - content := "" - content += "---\n" - content += "title: test\n" - content += "---\n" - content += "\n" - content += "Write your post here.\n" - - os.WriteFile(path.Join(user.PostDir(), "foo/index.md"), []byte(content), 0644) - os.WriteFile(path.Join(user.PostDir(), "foo/bar/index.md"), []byte(content), 0644) - posts, _ := user.PublishedPosts() - postIds := []string{} - for _, p := range posts { - postIds = append(postIds, p.Id()) - } - if !contains(postIds, "foo") { - t.Error("Does not contain post: foo. Found:") - for _, p := range posts { - t.Error("\t" + p.Id()) - } - } - - if contains(postIds, "foo/bar") { - t.Error("Invalid post found: foo/bar. Found:") - for _, p := range posts { - t.Error("\t" + p.Id()) - } - } -} - -func TestCannotListUserPostsWithoutIndexMd(t *testing.T) { - // Create a new user - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser(randomUserName()) - // Create a new post - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - os.Mkdir(path.Join(user.PostDir(), "foo"), 0755) - os.Mkdir(path.Join(user.PostDir(), "foo/bar"), 0755) - content := "" - content += "---\n" - content += "title: test\n" - content += "---\n" - content += "\n" - content += "Write your post here.\n" - - os.WriteFile(path.Join(user.PostDir(), "foo/bar/index.md"), []byte(content), 0644) - posts, _ := user.PublishedPosts() - postIds := []string{} - for _, p := range posts { - postIds = append(postIds, p.Id()) - } - if contains(postIds, "foo") { - t.Error("Contains invalid post: foo. Found:") - for _, p := range posts { - t.Error("\t" + p.Id()) - } - } -} - -func TestListUserPostsDoesNotIncludeDrafts(t *testing.T) { - // Create a new user - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser(randomUserName()) - // Create a new post - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - content := "" - content += "---\n" - content += "title: test\n" - content += "draft: true\n" - content += "---\n" - content += "\n" - content += "Write your post here.\n" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - posts, _ := user.PublishedPosts() - assertions.AssertLen(t, posts, 0) -} - -func TestListUsersDraftsExcludedRealWorld(t *testing.T) { - // Create a new user - repo := getTestRepo(owl.RepoConfig{}) - user, _ := repo.CreateUser(randomUserName()) - // Create a new post - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - content := "" - content += "---\n" - content += "title: Articles September 2019\n" - content += "author: h4kor\n" - content += "type: post\n" - content += "date: -001-11-30T00:00:00+00:00\n" - content += "draft: true\n" - content += "url: /?p=426\n" - content += "categories:\n" - content += " - Uncategorised\n" - content += "\n" - content += "---\n" - content += "\n" - - os.WriteFile(post.ContentFile(), []byte(content), 0644) - - posts, _ := user.PublishedPosts() - assertions.AssertLen(t, posts, 0) -} - -func TestCanLoadPost(t *testing.T) { - user := getTestUser() - // Create a new post - user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - - posts, _ := user.PublishedPosts() - post, _ := user.GetPost(posts[0].Id()) - assertions.Assert(t, post.Title() == "testpost", "Post title is not correct") -} - -func TestUserUrlPath(t *testing.T) { - user := getTestUser() - assertions.Assert(t, user.UrlPath() == "/user/"+user.Name()+"/", "Wrong url path") -} - -func TestUserFullUrl(t *testing.T) { - user := getTestUser() - assertions.Assert(t, user.FullUrl() == "http://localhost:8080/user/"+user.Name()+"/", "Wrong url path") -} - -func TestPostsSortedByPublishingDateLatestFirst(t *testing.T) { - user := getTestUser() - // Create a new post - post1, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - post2, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost2"}, "") - - content := "---\n" - content += "title: Test Post\n" - content += "date: Wed, 17 Aug 2022 10:50:02 +0000\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post1.ContentFile(), []byte(content), 0644) - - content = "---\n" - content += "title: Test Post 2\n" - content += "date: Wed, 17 Aug 2022 20:50:06 +0000\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post2.ContentFile(), []byte(content), 0644) - - posts, _ := user.PublishedPosts() - assertions.Assert(t, posts[0].Id() == post2.Id(), "Wrong Id") - assertions.Assert(t, posts[1].Id() == post1.Id(), "Wrong Id") -} - -func TestPostsSortedByPublishingDateLatestFirst2(t *testing.T) { - user := getTestUser() - // Create a new post - posts := []owl.Post{} - for i := 59; i >= 0; i-- { - post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - content := "---\n" - content += "title: Test Post\n" - content += fmt.Sprintf("date: Wed, 17 Aug 2022 10:%02d:02 +0000\n", i) - content += "---\n" - content += "This is a test" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - posts = append(posts, post) - } - - retPosts, _ := user.PublishedPosts() - for i, p := range retPosts { - assertions.Assert(t, p.Id() == posts[i].Id(), "Wrong Id") - } -} - -func TestPostsSortedByPublishingDateBrokenAtBottom(t *testing.T) { - user := getTestUser() - // Create a new post - post1, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "") - post2, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost2"}, "") - - content := "---\n" - content += "title: Test Post\n" - content += "date: Wed, 17 +0000\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post1.ContentFile(), []byte(content), 0644) - - content = "---\n" - content += "title: Test Post 2\n" - content += "date: Wed, 17 Aug 2022 20:50:06 +0000\n" - content += "---\n" - content += "This is a test" - os.WriteFile(post2.ContentFile(), []byte(content), 0644) - - posts, _ := user.PublishedPosts() - assertions.Assert(t, posts[0].Id() == post2.Id(), "Wrong Id") - assertions.Assert(t, posts[1].Id() == post1.Id(), "Wrong Id") -} - -func TestAvatarEmptyIfNotExist(t *testing.T) { - user := getTestUser() - assertions.Assert(t, user.AvatarUrl() == "", "Avatar should be empty") -} - -func TestAvatarSetIfFileExist(t *testing.T) { - user := getTestUser() - os.WriteFile(path.Join(user.MediaDir(), "avatar.png"), []byte("test"), 0644) - assertions.Assert(t, user.AvatarUrl() != "", "Avatar should not be empty") -} - -func TestPostNameIllegalFileName(t *testing.T) { - user := getTestUser() - _, err := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost?///"}, "") - assertions.AssertNoError(t, err, "Should not have failed") -} - -func TestFaviconIfNotExist(t *testing.T) { - user := getTestUser() - assertions.Assert(t, user.FaviconUrl() == "", "Favicon should be empty") -} - -func TestFaviconSetIfFileExist(t *testing.T) { - user := getTestUser() - os.WriteFile(path.Join(user.MediaDir(), "favicon.ico"), []byte("test"), 0644) - assertions.Assert(t, user.FaviconUrl() != "", "Favicon should not be empty") -} - -func TestResetUserPassword(t *testing.T) { - user := getTestUser() - user.ResetPassword("test") - assertions.Assert(t, user.Config().PassworHash != "", "Password Hash should not be empty") - assertions.Assert(t, user.Config().PassworHash != "test", "Password Hash should not be test") -} - -func TestVerifyPassword(t *testing.T) { - user := getTestUser() - user.ResetPassword("test") - assertions.Assert(t, user.VerifyPassword("test"), "Password should be correct") - assertions.Assert(t, !user.VerifyPassword("test2"), "Password should be incorrect") - assertions.Assert(t, !user.VerifyPassword(""), "Password should be incorrect") - assertions.Assert(t, !user.VerifyPassword("Test"), "Password should be incorrect") - assertions.Assert(t, !user.VerifyPassword("TEST"), "Password should be incorrect") - assertions.Assert(t, !user.VerifyPassword("0000000"), "Password should be incorrect") - -} - -func TestValidateAccessTokenWrongToken(t *testing.T) { - user := getTestUser() - code, _ := user.GenerateAuthCode( - "test", "test", "test", "test", "test", - ) - user.GenerateAccessToken(owl.AuthCode{ - Code: code, - ClientId: "test", - RedirectUri: "test", - CodeChallenge: "test", - CodeChallengeMethod: "test", - Scope: "test", - }) - valid, _ := user.ValidateAccessToken("test") - assertions.Assert(t, !valid, "Token should be invalid") -} - -func TestValidateAccessTokenCorrectToken(t *testing.T) { - user := getTestUser() - code, _ := user.GenerateAuthCode( - "test", "test", "test", "test", "test", - ) - token, _, _ := user.GenerateAccessToken(owl.AuthCode{ - Code: code, - ClientId: "test", - RedirectUri: "test", - CodeChallenge: "test", - CodeChallengeMethod: "test", - Scope: "test", - }) - valid, aToken := user.ValidateAccessToken(token) - assertions.Assert(t, valid, "Token should be valid") - assertions.Assert(t, aToken.ClientId == "test", "Token should be valid") - assertions.Assert(t, aToken.Token == token, "Token should be valid") -} diff --git a/utils.go b/utils.go deleted file mode 100644 index 27083a2..0000000 --- a/utils.go +++ /dev/null @@ -1,16 +0,0 @@ -package owl - -import ( - "crypto/rand" - "math/big" -) - -func GenerateRandomString(length int) string { - chars := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") - b := make([]rune, length) - for i := range b { - k, _ := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) - b[i] = chars[k.Int64()] - } - return string(b) -} diff --git a/web/app.go b/web/app.go new file mode 100644 index 0000000..738f2df --- /dev/null +++ b/web/app.go @@ -0,0 +1,115 @@ +package web + +import ( + "embed" + "net/http" + "owl-blogs/app" + "owl-blogs/app/repository" + "owl-blogs/web/middleware" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/filesystem" +) + +//go:embed static/* +var embedDirStatic embed.FS + +type WebApp struct { + FiberApp *fiber.App + EntryService *app.EntryService + BinaryService *app.BinaryService + Registry *app.EntryTypeRegistry + AuthorService *app.AuthorService + SiteConfigRepo repository.ConfigRepository +} + +func NewWebApp( + entryService *app.EntryService, + typeRegistry *app.EntryTypeRegistry, + binService *app.BinaryService, + authorService *app.AuthorService, + configRepo repository.ConfigRepository, +) *WebApp { + app := fiber.New() + + indexHandler := NewIndexHandler(entryService, configRepo) + listHandler := NewListHandler(entryService, configRepo) + entryHandler := NewEntryHandler(entryService, typeRegistry, authorService, configRepo) + mediaHandler := NewMediaHandler(binService) + rssHandler := NewRSSHandler(entryService) + loginHandler := NewLoginHandler(authorService, configRepo) + editorListHandler := NewEditorListHandler(typeRegistry, configRepo) + editorHandler := NewEditorHandler(entryService, typeRegistry, binService, configRepo) + + // Login + app.Get("/auth/login", loginHandler.HandleGet) + app.Post("/auth/login", loginHandler.HandlePost) + + // Editor + editor := app.Group("/editor") + editor.Use(middleware.NewAuthMiddleware(authorService).Handle) + editor.Get("/", editorListHandler.Handle) + editor.Get("/:editor/", editorHandler.HandleGet) + editor.Post("/:editor/", editorHandler.HandlePost) + + // SiteConfig + siteConfig := app.Group("/site-config") + siteConfig.Use(middleware.NewAuthMiddleware(authorService).Handle) + + siteConfigHandler := NewSiteConfigHandler(configRepo) + siteConfig.Get("/", siteConfigHandler.HandleGet) + siteConfig.Post("/", siteConfigHandler.HandlePost) + + siteConfigMeHandler := NewSiteConfigMeHandler(configRepo) + siteConfig.Get("/me", siteConfigMeHandler.HandleGet) + siteConfig.Post("/me/create/", siteConfigMeHandler.HandleCreate) + siteConfig.Post("/me/delete/", siteConfigMeHandler.HandleDelete) + + siteConfigListHandler := NewSiteConfigListHandler(configRepo, typeRegistry) + siteConfig.Get("/lists", siteConfigListHandler.HandleGet) + siteConfig.Post("/lists/create/", siteConfigListHandler.HandleCreate) + siteConfig.Post("/lists/delete/", siteConfigListHandler.HandleDelete) + + siteConfigMenusHandler := NewSiteConfigMenusHandler(configRepo) + siteConfig.Get("/menus", siteConfigMenusHandler.HandleGet) + siteConfig.Post("/menus/create/", siteConfigMenusHandler.HandleCreate) + siteConfig.Post("/menus/delete/", siteConfigMenusHandler.HandleDelete) + + // app.Static("/static/*filepath", http.Dir(repo.StaticDir())) + app.Use("/static", filesystem.New(filesystem.Config{ + Root: http.FS(embedDirStatic), + PathPrefix: "static", + Browse: false, + })) + app.Get("/", indexHandler.Handle) + app.Get("/lists/:list/", listHandler.Handle) + // Media + app.Get("/media/+", mediaHandler.Handle) + // RSS + app.Get("/index.xml", rssHandler.Handle) + // Posts + app.Get("/posts/:post/", entryHandler.Handle) + // Webmention + // app.Post("/webmention/", userWebmentionHandler(repo)) + // Micropub + // app.Post("/micropub/", userMicropubHandler(repo)) + // IndieAuth + // app.Get("/auth/", userAuthHandler(repo)) + // app.Post("/auth/", userAuthProfileHandler(repo)) + // app.Post("/auth/verify/", userAuthVerifyHandler(repo)) + // app.Post("/auth/token/", userAuthTokenHandler(repo)) + // app.Get("/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo)) + // app.NotFound = http.HandlerFunc(notFoundHandler(repo)) + return &WebApp{ + FiberApp: app, + EntryService: entryService, + Registry: typeRegistry, + BinaryService: binService, + AuthorService: authorService, + SiteConfigRepo: configRepo, + } +} + +func (w *WebApp) Run() { + w.FiberApp.Listen(":3000") +} diff --git a/web/editor/entry_form.go b/web/editor/entry_form.go new file mode 100644 index 0000000..9857f2b --- /dev/null +++ b/web/editor/entry_form.go @@ -0,0 +1,171 @@ +package editor + +import ( + "fmt" + "mime/multipart" + "owl-blogs/app" + "owl-blogs/domain/model" + "reflect" + "strings" +) + +type HttpFormData interface { + // FormFile returns the first file by key from a MultipartForm. + FormFile(key string) (*multipart.FileHeader, error) + // FormValue returns the first value by key from a MultipartForm. + // Search is performed in QueryArgs, PostArgs, MultipartForm and FormFile in this particular order. + // Defaults to the empty string "" if the form value doesn't exist. + // If a default value is given, it will return that value if the form value does not exist. + // Returned value is only valid within the handler. Do not store any references. + // Make copies or use the Immutable setting instead. + FormValue(key string, defaultValue ...string) string +} + +type EditorEntryForm struct { + entry model.Entry + binSvc *app.BinaryService +} + +type EntryFormFieldParams struct { + InputType string + Widget string +} + +type EntryFormField struct { + Name string + Params EntryFormFieldParams +} + +func NewEntryForm(entry model.Entry, binaryService *app.BinaryService) *EditorEntryForm { + return &EditorEntryForm{ + entry: entry, + binSvc: binaryService, + } +} + +func (s *EntryFormFieldParams) ApplyTag(tagKey string, tagValue string) error { + switch tagKey { + case "inputType": + s.InputType = tagValue + case "widget": + s.Widget = tagValue + default: + return fmt.Errorf("unknown tag key: %v", tagKey) + } + return nil +} + +func (s *EntryFormField) Html() string { + html := "" + html += fmt.Sprintf("\n", s.Name, s.Name) + if s.Params.InputType == "text" && s.Params.Widget == "textarea" { + html += fmt.Sprintf("\n", s.Name, s.Name) + } else { + html += fmt.Sprintf("\n", s.Params.InputType, s.Name, s.Name) + } + return html +} + +func FieldToFormField(field reflect.StructField) (EntryFormField, error) { + formField := EntryFormField{ + Name: field.Name, + Params: EntryFormFieldParams{}, + } + tag := field.Tag.Get("owl") + for _, param := range strings.Split(tag, " ") { + parts := strings.Split(param, "=") + if len(parts) != 2 { + continue + } + err := formField.Params.ApplyTag(parts[0], parts[1]) + if err != nil { + return EntryFormField{}, err + } + } + return formField, nil +} + +func StructToFormFields(meta interface{}) ([]EntryFormField, error) { + entryType := reflect.TypeOf(meta).Elem() + numFields := entryType.NumField() + fields := []EntryFormField{} + for i := 0; i < numFields; i++ { + field, err := FieldToFormField(entryType.Field(i)) + if err != nil { + return nil, err + } + fields = append(fields, field) + } + return fields, nil +} + +func (s *EditorEntryForm) HtmlForm() (string, error) { + meta := s.entry.MetaData() + fields, err := StructToFormFields(meta) + if err != nil { + return "", err + } + + html := "
      \n" + for _, field := range fields { + html += field.Html() + } + html += "\n" + html += "
      \n" + + return html, nil +} + +func (s *EditorEntryForm) Parse(ctx HttpFormData) (model.Entry, error) { + if ctx == nil { + return nil, fmt.Errorf("nil context") + } + meta := s.entry.MetaData() + metaVal := reflect.ValueOf(meta) + if metaVal.Kind() != reflect.Ptr { + return nil, fmt.Errorf("meta data is not a pointer") + } + fields, err := StructToFormFields(meta) + if err != nil { + return nil, err + } + for _, field := range fields { + fieldName := field.Name + + if field.Params.InputType == "file" { + file, err := ctx.FormFile(fieldName) + if err != nil { + return nil, err + } + fileData, err := file.Open() + if err != nil { + return nil, err + } + defer fileData.Close() + fileBytes := make([]byte, file.Size) + _, err = fileData.Read(fileBytes) + if err != nil { + return nil, err + } + + binaryFile, err := s.binSvc.Create(file.Filename, fileBytes) + if err != nil { + return nil, err + } + + metaField := metaVal.Elem().FieldByName(fieldName) + if metaField.IsValid() { + metaField.SetString(binaryFile.Id) + } + } else { + formValue := ctx.FormValue(fieldName) + metaField := metaVal.Elem().FieldByName(fieldName) + if metaField.IsValid() { + metaField.SetString(formValue) + } + } + + } + + return s.entry, nil +} diff --git a/web/editor/entry_form_test.go b/web/editor/entry_form_test.go new file mode 100644 index 0000000..4068254 --- /dev/null +++ b/web/editor/entry_form_test.go @@ -0,0 +1,133 @@ +package editor_test + +import ( + "bytes" + "io" + "mime/multipart" + "os" + "owl-blogs/app" + "owl-blogs/domain/model" + "owl-blogs/infra" + "owl-blogs/test" + "owl-blogs/web/editor" + "path" + "path/filepath" + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +type MockEntryMetaData struct { + Image string `owl:"inputType=file"` + Content string `owl:"inputType=text"` +} + +type MockFormData struct { + fileHeader *multipart.FileHeader +} + +func NewMockFormData() *MockFormData { + fileDir, _ := os.Getwd() + fileName := "../../test/fixtures/test.png" + filePath := path.Join(fileDir, fileName) + + file, err := os.Open(filePath) + if err != nil { + panic(err) + } + defer file.Close() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("ImagePath", filepath.Base(file.Name())) + if err != nil { + panic(err) + } + io.Copy(part, file) + writer.Close() + + multipartForm := multipart.NewReader(body, writer.Boundary()) + formData, err := multipartForm.ReadForm(0) + if err != nil { + panic(err) + } + fileHeader := formData.File["ImagePath"][0] + + return &MockFormData{fileHeader: fileHeader} +} + +func (f *MockFormData) FormFile(key string) (*multipart.FileHeader, error) { + return f.fileHeader, nil +} + +func (f *MockFormData) FormValue(key string, defaultValue ...string) string { + return key +} + +type MockEntry struct { + model.EntryBase + metaData MockEntryMetaData +} + +func (e *MockEntry) Content() model.EntryContent { + return model.EntryContent(e.metaData.Content) +} + +func (e *MockEntry) MetaData() interface{} { + return &e.metaData +} + +func (e *MockEntry) SetMetaData(metaData interface{}) { + e.metaData = *metaData.(*MockEntryMetaData) +} + +func (e *MockEntry) Title() string { + return "" +} + +func TestFieldToFormField(t *testing.T) { + field := reflect.TypeOf(&MockEntryMetaData{}).Elem().Field(0) + formField, err := editor.FieldToFormField(field) + require.NoError(t, err) + require.Equal(t, "Image", formField.Name) + require.Equal(t, "file", formField.Params.InputType) +} + +func TestStructToFields(t *testing.T) { + fields, err := editor.StructToFormFields(&MockEntryMetaData{}) + require.NoError(t, err) + require.Len(t, fields, 2) + require.Equal(t, "Image", fields[0].Name) + require.Equal(t, "file", fields[0].Params.InputType) + require.Equal(t, "Content", fields[1].Name) + require.Equal(t, "text", fields[1].Params.InputType) +} + +func TestEditorEntryForm_HtmlForm(t *testing.T) { + form := editor.NewEntryForm(&MockEntry{}, nil) + html, err := form.HtmlForm() + require.NoError(t, err) + require.Contains(t, html, " len(entries) { + offset = len(entries) + lastPage = true + } + if offset+limit > len(entries) { + limit = len(entries) - offset + lastPage = true + } + entries = entries[offset : offset+limit] + + if err != nil { + return err + } + + return render.RenderTemplateWithBase(c, siteConfig, "views/index", indexRenderData{ + Entries: entries, + Page: pageNum, + NextPage: pageNum + 1, + PrevPage: pageNum - 1, + FirstPage: pageNum == 1, + LastPage: lastPage, + }) + +} diff --git a/web/list_handler.go b/web/list_handler.go new file mode 100644 index 0000000..221b033 --- /dev/null +++ b/web/list_handler.go @@ -0,0 +1,100 @@ +package web + +import ( + "owl-blogs/app" + "owl-blogs/app/repository" + "owl-blogs/domain/model" + "owl-blogs/render" + "sort" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +type ListHandler struct { + configRepo repository.ConfigRepository + entrySvc *app.EntryService +} + +func NewListHandler( + entryService *app.EntryService, + configRepo repository.ConfigRepository, +) *ListHandler { + return &ListHandler{ + entrySvc: entryService, + configRepo: configRepo, + } +} + +type listRenderData struct { + List model.EntryList + Entries []model.Entry + Page int + NextPage int + PrevPage int + FirstPage bool + LastPage bool +} + +func (h *ListHandler) Handle(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + siteConfig := getSiteConfig(h.configRepo) + listId := c.Params("list") + list := model.EntryList{} + for _, l := range siteConfig.Lists { + if l.Id == listId { + list = l + } + } + if list.Id == "" { + return c.SendStatus(404) + } + + entries, err := h.entrySvc.FindAllByType(&list.Include, true, false) + if err != nil { + return err + } + + // sort entries by date descending + sort.Slice(entries, func(i, j int) bool { + return entries[i].PublishedAt().After(*entries[j].PublishedAt()) + }) + + // pagination + page := c.Query("page") + if page == "" { + page = "1" + } + pageNum, err := strconv.Atoi(page) + if err != nil { + pageNum = 1 + } + limit := 10 + offset := (pageNum - 1) * limit + lastPage := false + if offset > len(entries) { + offset = len(entries) + lastPage = true + } + if offset+limit > len(entries) { + limit = len(entries) - offset + lastPage = true + } + entries = entries[offset : offset+limit] + + if err != nil { + return err + } + + return render.RenderTemplateWithBase(c, siteConfig, "views/list", listRenderData{ + List: list, + Entries: entries, + Page: pageNum, + NextPage: pageNum + 1, + PrevPage: pageNum - 1, + FirstPage: pageNum == 1, + LastPage: lastPage, + }) + +} diff --git a/web/login_handler.go b/web/login_handler.go new file mode 100644 index 0000000..11ae56d --- /dev/null +++ b/web/login_handler.go @@ -0,0 +1,57 @@ +package web + +import ( + "owl-blogs/app" + "owl-blogs/app/repository" + "owl-blogs/render" + "time" + + "github.com/gofiber/fiber/v2" +) + +type LoginHandler struct { + configRepo repository.ConfigRepository + authorService *app.AuthorService +} + +func NewLoginHandler( + authorService *app.AuthorService, + configRepo repository.ConfigRepository, +) *LoginHandler { + return &LoginHandler{ + authorService: authorService, + configRepo: configRepo, + } +} + +func (h *LoginHandler) HandleGet(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + return render.RenderTemplateWithBase(c, getSiteConfig(h.configRepo), "views/login", nil) +} + +func (h *LoginHandler) HandlePost(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + name := c.FormValue("name") + password := c.FormValue("password") + + valid := h.authorService.Authenticate(name, password) + if !valid { + return c.Redirect("/auth/login") + } + + token, err := h.authorService.CreateToken(name) + if err != nil { + return err + } + + cookie := fiber.Cookie{ + Name: "token", + Value: token, + Expires: time.Now().Add(30 * 24 * time.Hour), + HTTPOnly: true, + } + c.Cookie(&cookie) + + return c.Redirect("/editor/") + +} diff --git a/web/media_handler.go b/web/media_handler.go new file mode 100644 index 0000000..71271d2 --- /dev/null +++ b/web/media_handler.go @@ -0,0 +1,30 @@ +package web + +import ( + "net/url" + "owl-blogs/app" + + "github.com/gofiber/fiber/v2" +) + +type MediaHandler struct { + binaryService *app.BinaryService +} + +func NewMediaHandler(binaryService *app.BinaryService) *MediaHandler { + return &MediaHandler{binaryService: binaryService} +} + +func (h *MediaHandler) Handle(c *fiber.Ctx) error { + id := c.Params("+") + // urldecode + id, err := url.PathUnescape(id) + if err != nil { + return err + } + binary, err := h.binaryService.FindById(id) + if err != nil { + return err + } + return c.Send(binary.Data) +} diff --git a/web/middleware/auth.go b/web/middleware/auth.go new file mode 100644 index 0000000..6127489 --- /dev/null +++ b/web/middleware/auth.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "owl-blogs/app" + + "github.com/gofiber/fiber/v2" +) + +type AuthMiddleware struct { + authorService *app.AuthorService +} + +func NewAuthMiddleware(authorService *app.AuthorService) *AuthMiddleware { + return &AuthMiddleware{authorService: authorService} +} + +func (m *AuthMiddleware) Handle(c *fiber.Ctx) error { + // get token from cookie + token := c.Cookies("token") + if token == "" { + return c.Redirect("/auth/login") + } + + // check token + valid, name := m.authorService.ValidateToken(token) + if !valid { + return c.Redirect("/auth/login") + } + + // set author name to context + c.Locals("author", name) + + return c.Next() +} diff --git a/web/rss_handler.go b/web/rss_handler.go new file mode 100644 index 0000000..dc12784 --- /dev/null +++ b/web/rss_handler.go @@ -0,0 +1,19 @@ +package web + +import ( + "owl-blogs/app" + + "github.com/gofiber/fiber/v2" +) + +type RSSHandler struct { + entrySvc *app.EntryService +} + +func NewRSSHandler(entryService *app.EntryService) *RSSHandler { + return &RSSHandler{entrySvc: entryService} +} + +func (h *RSSHandler) Handle(c *fiber.Ctx) error { + return c.SendString("Hello, RSS!") +} diff --git a/web/siteconfig_handler.go b/web/siteconfig_handler.go new file mode 100644 index 0000000..0e62e25 --- /dev/null +++ b/web/siteconfig_handler.go @@ -0,0 +1,56 @@ +package web + +import ( + "owl-blogs/app/repository" + "owl-blogs/config" + "owl-blogs/domain/model" + "owl-blogs/render" + + "github.com/gofiber/fiber/v2" +) + +type SiteConfigHandler struct { + siteConfigRepo repository.ConfigRepository +} + +func NewSiteConfigHandler(siteConfigRepo repository.ConfigRepository) *SiteConfigHandler { + return &SiteConfigHandler{ + siteConfigRepo: siteConfigRepo, + } +} + +func (h *SiteConfigHandler) HandleGet(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + if err != nil { + return err + } + + return render.RenderTemplateWithBase(c, getSiteConfig(h.siteConfigRepo), "views/site_config", siteConfig) +} + +func (h *SiteConfigHandler) HandlePost(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + + if err != nil { + return err + } + + siteConfig.Title = c.FormValue("Title") + siteConfig.SubTitle = c.FormValue("SubTitle") + siteConfig.HeaderColor = c.FormValue("HeaderColor") + siteConfig.AuthorName = c.FormValue("AuthorName") + siteConfig.AvatarUrl = c.FormValue("AvatarUrl") + + err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) + if err != nil { + return err + } + + return c.Redirect("/site-config/") +} diff --git a/web/siteconfig_list_handler.go b/web/siteconfig_list_handler.go new file mode 100644 index 0000000..963e3e5 --- /dev/null +++ b/web/siteconfig_list_handler.go @@ -0,0 +1,113 @@ +package web + +import ( + "owl-blogs/app" + "owl-blogs/app/repository" + "owl-blogs/config" + "owl-blogs/domain/model" + "owl-blogs/render" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +type SiteConfigListHandler struct { + siteConfigRepo repository.ConfigRepository + typeRegistry *app.EntryTypeRegistry +} + +type siteConfigListTemplateData struct { + Lists []model.EntryList + Types []string +} + +func NewSiteConfigListHandler( + siteConfigRepo repository.ConfigRepository, + typeRegistry *app.EntryTypeRegistry, +) *SiteConfigListHandler { + return &SiteConfigListHandler{ + siteConfigRepo: siteConfigRepo, + typeRegistry: typeRegistry, + } +} + +func (h *SiteConfigListHandler) HandleGet(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + + if err != nil { + return err + } + + types := make([]string, 0) + for _, t := range h.typeRegistry.Types() { + typeName, err := h.typeRegistry.TypeName(t) + if err != nil { + continue + } + types = append(types, typeName) + } + + return render.RenderTemplateWithBase( + c, getSiteConfig(h.siteConfigRepo), "views/site_config_list", siteConfigListTemplateData{ + Lists: siteConfig.Lists, + Types: types, + }) +} + +func (h *SiteConfigListHandler) HandleCreate(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + + if err != nil { + return err + } + + form, err := c.MultipartForm() + if err != nil { + return err + } + + siteConfig.Lists = append(siteConfig.Lists, model.EntryList{ + Id: c.FormValue("Id"), + Title: c.FormValue("Title"), + Include: form.Value["Include"], + ListType: c.FormValue("ListType"), + }) + + err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) + if err != nil { + return err + } + + return c.Redirect("/site-config/lists") +} + +func (h *SiteConfigListHandler) HandleDelete(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + + if err != nil { + return err + } + + id, err := strconv.Atoi(c.FormValue("idx")) + if err != nil { + return err + } + + siteConfig.Lists = append(siteConfig.Lists[:id], siteConfig.Lists[id+1:]...) + + err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) + if err != nil { + return err + } + + return c.Redirect("/site-config/lists") +} diff --git a/web/siteconfig_me_handler.go b/web/siteconfig_me_handler.go new file mode 100644 index 0000000..3fdad35 --- /dev/null +++ b/web/siteconfig_me_handler.go @@ -0,0 +1,82 @@ +package web + +import ( + "owl-blogs/app/repository" + "owl-blogs/config" + "owl-blogs/domain/model" + "owl-blogs/render" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +type SiteConfigMeHandler struct { + siteConfigRepo repository.ConfigRepository +} + +func NewSiteConfigMeHandler(siteConfigRepo repository.ConfigRepository) *SiteConfigMeHandler { + return &SiteConfigMeHandler{ + siteConfigRepo: siteConfigRepo, + } +} + +func (h *SiteConfigMeHandler) HandleGet(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + + if err != nil { + return err + } + + return render.RenderTemplateWithBase( + c, getSiteConfig(h.siteConfigRepo), "views/site_config_me", siteConfig.Me) +} + +func (h *SiteConfigMeHandler) HandleCreate(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + + if err != nil { + return err + } + + siteConfig.Me = append(siteConfig.Me, model.MeLinks{ + Name: c.FormValue("Name"), + Url: c.FormValue("Url"), + }) + + err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) + if err != nil { + return err + } + + return c.Redirect("/site-config/me") +} + +func (h *SiteConfigMeHandler) HandleDelete(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + + if err != nil { + return err + } + + idx, err := strconv.Atoi(c.FormValue("idx")) + if err != nil { + return err + } + siteConfig.Me = append(siteConfig.Me[:idx], siteConfig.Me[idx+1:]...) + + err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) + if err != nil { + return err + } + + return c.Redirect("/site-config/me") +} diff --git a/web/siteconfig_menus_handler.go b/web/siteconfig_menus_handler.go new file mode 100644 index 0000000..3babe6b --- /dev/null +++ b/web/siteconfig_menus_handler.go @@ -0,0 +1,104 @@ +package web + +import ( + "owl-blogs/app/repository" + "owl-blogs/config" + "owl-blogs/domain/model" + "owl-blogs/render" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +type SiteConfigMenusHandler struct { + siteConfigRepo repository.ConfigRepository +} + +type siteConfigMenusTemplateData struct { + HeaderMenu []model.MenuItem + FooterMenu []model.MenuItem +} + +func NewSiteConfigMenusHandler(siteConfigRepo repository.ConfigRepository) *SiteConfigMenusHandler { + return &SiteConfigMenusHandler{ + siteConfigRepo: siteConfigRepo, + } +} + +func (h *SiteConfigMenusHandler) HandleGet(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + + if err != nil { + return err + } + + return render.RenderTemplateWithBase( + c, getSiteConfig(h.siteConfigRepo), "views/site_config_menus", siteConfigMenusTemplateData{ + HeaderMenu: siteConfig.HeaderMenu, + FooterMenu: siteConfig.FooterMenu, + }) +} + +func (h *SiteConfigMenusHandler) HandleCreate(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + + if err != nil { + return err + } + + menuItem := model.MenuItem{ + Title: c.FormValue("Title"), + List: c.FormValue("List"), + Url: c.FormValue("Url"), + Post: c.FormValue("Post"), + } + + if c.FormValue("menu") == "header" { + siteConfig.HeaderMenu = append(siteConfig.HeaderMenu, menuItem) + } else if c.FormValue("menu") == "footer" { + siteConfig.FooterMenu = append(siteConfig.FooterMenu, menuItem) + } + + err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) + if err != nil { + return err + } + + return c.Redirect("/site-config/menus") +} + +func (h *SiteConfigMenusHandler) HandleDelete(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + + if err != nil { + return err + } + + menu := c.FormValue("menu") + idx, err := strconv.Atoi(c.FormValue("idx")) + if err != nil { + return err + } + + if menu == "header" { + siteConfig.HeaderMenu = append(siteConfig.HeaderMenu[:idx], siteConfig.HeaderMenu[idx+1:]...) + } else if menu == "footer" { + siteConfig.FooterMenu = append(siteConfig.FooterMenu[:idx], siteConfig.FooterMenu[idx+1:]...) + } + + err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) + if err != nil { + return err + } + + return c.Redirect("/site-config/menus") +} diff --git a/web/static/favicon.ico b/web/static/favicon.ico new file mode 100644 index 0000000..c2f7f35 Binary files /dev/null and b/web/static/favicon.ico differ diff --git a/web/static/pico.min.css b/web/static/pico.min.css new file mode 100644 index 0000000..a130009 --- /dev/null +++ b/web/static/pico.min.css @@ -0,0 +1,5 @@ +@charset "UTF-8";/*! + * Pico CSS v1.5.9 (https://picocss.com) + * Copyright 2019-2023 - Licensed under MIT + */:root{--font-family:system-ui,-apple-system,"Segoe UI","Roboto","Ubuntu","Cantarell","Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--line-height:1.5;--font-weight:400;--font-size:16px;--border-radius:0.25rem;--border-width:1px;--outline-width:3px;--spacing:1rem;--typography-spacing-vertical:1.5rem;--block-spacing-vertical:calc(var(--spacing) * 2);--block-spacing-horizontal:var(--spacing);--grid-spacing-vertical:0;--grid-spacing-horizontal:var(--spacing);--form-element-spacing-vertical:0.75rem;--form-element-spacing-horizontal:1rem;--nav-element-spacing-vertical:1rem;--nav-element-spacing-horizontal:0.5rem;--nav-link-spacing-vertical:0.5rem;--nav-link-spacing-horizontal:0.5rem;--form-label-font-weight:var(--font-weight);--transition:0.2s ease-in-out;--modal-overlay-backdrop-filter:blur(0.25rem)}@media (min-width:576px){:root{--font-size:17px}}@media (min-width:768px){:root{--font-size:18px}}@media (min-width:992px){:root{--font-size:19px}}@media (min-width:1200px){:root{--font-size:20px}}@media (min-width:576px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 2.5)}}@media (min-width:768px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 3)}}@media (min-width:992px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 3.5)}}@media (min-width:1200px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 4)}}@media (min-width:576px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.25)}}@media (min-width:768px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.5)}}@media (min-width:992px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.75)}}@media (min-width:1200px){article{--block-spacing-horizontal:calc(var(--spacing) * 2)}}dialog>article{--block-spacing-vertical:calc(var(--spacing) * 2);--block-spacing-horizontal:var(--spacing)}@media (min-width:576px){dialog>article{--block-spacing-vertical:calc(var(--spacing) * 2.5);--block-spacing-horizontal:calc(var(--spacing) * 1.25)}}@media (min-width:768px){dialog>article{--block-spacing-vertical:calc(var(--spacing) * 3);--block-spacing-horizontal:calc(var(--spacing) * 1.5)}}a{--text-decoration:none}a.contrast,a.secondary{--text-decoration:underline}small{--font-size:0.875em}h1,h2,h3,h4,h5,h6{--font-weight:700}h1{--font-size:2rem;--typography-spacing-vertical:3rem}h2{--font-size:1.75rem;--typography-spacing-vertical:2.625rem}h3{--font-size:1.5rem;--typography-spacing-vertical:2.25rem}h4{--font-size:1.25rem;--typography-spacing-vertical:1.874rem}h5{--font-size:1.125rem;--typography-spacing-vertical:1.6875rem}[type=checkbox],[type=radio]{--border-width:2px}[type=checkbox][role=switch]{--border-width:3px}tfoot td,tfoot th,thead td,thead th{--border-width:3px}:not(thead,tfoot)>*>td{--font-size:0.875em}code,kbd,pre,samp{--font-family:"Menlo","Consolas","Roboto Mono","Ubuntu Monospace","Noto Mono","Oxygen Mono","Liberation Mono",monospace,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"}kbd{--font-weight:bolder}:root:not([data-theme=dark]),[data-theme=light]{--background-color:#fff;--color:hsl(205, 20%, 32%);--h1-color:hsl(205, 30%, 15%);--h2-color:#24333e;--h3-color:hsl(205, 25%, 23%);--h4-color:#374956;--h5-color:hsl(205, 20%, 32%);--h6-color:#4d606d;--muted-color:hsl(205, 10%, 50%);--muted-border-color:hsl(205, 20%, 94%);--primary:hsl(195, 85%, 41%);--primary-hover:hsl(195, 90%, 32%);--primary-focus:rgba(16, 149, 193, 0.125);--primary-inverse:#fff;--secondary:hsl(205, 15%, 41%);--secondary-hover:hsl(205, 20%, 32%);--secondary-focus:rgba(89, 107, 120, 0.125);--secondary-inverse:#fff;--contrast:hsl(205, 30%, 15%);--contrast-hover:#000;--contrast-focus:rgba(89, 107, 120, 0.125);--contrast-inverse:#fff;--mark-background-color:#fff2ca;--mark-color:#543a26;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:transparent;--form-element-border-color:hsl(205, 14%, 68%);--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:transparent;--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:hsl(205, 18%, 86%);--form-element-disabled-border-color:hsl(205, 14%, 68%);--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#c62828;--form-element-invalid-active-border-color:#d32f2f;--form-element-invalid-focus-color:rgba(211, 47, 47, 0.125);--form-element-valid-border-color:#388e3c;--form-element-valid-active-border-color:#43a047;--form-element-valid-focus-color:rgba(67, 160, 71, 0.125);--switch-background-color:hsl(205, 16%, 77%);--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:hsl(205, 18%, 86%);--range-active-border-color:hsl(205, 16%, 77%);--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:#f6f8f9;--code-background-color:hsl(205, 20%, 94%);--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:hsl(330, 40%, 50%);--code-property-color:hsl(185, 40%, 40%);--code-value-color:hsl(40, 20%, 50%);--code-comment-color:hsl(205, 14%, 68%);--accordion-border-color:var(--muted-border-color);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:var(--background-color);--card-border-color:var(--muted-border-color);--card-box-shadow:0.0145rem 0.029rem 0.174rem rgba(27, 40, 50, 0.01698),0.0335rem 0.067rem 0.402rem rgba(27, 40, 50, 0.024),0.0625rem 0.125rem 0.75rem rgba(27, 40, 50, 0.03),0.1125rem 0.225rem 1.35rem rgba(27, 40, 50, 0.036),0.2085rem 0.417rem 2.502rem rgba(27, 40, 50, 0.04302),0.5rem 1rem 6rem rgba(27, 40, 50, 0.06),0 0 0 0.0625rem rgba(27, 40, 50, 0.015);--card-sectionning-background-color:#fbfbfc;--dropdown-background-color:#fbfbfc;--dropdown-border-color:#e1e6eb;--dropdown-box-shadow:var(--card-box-shadow);--dropdown-color:var(--color);--dropdown-hover-background-color:hsl(205, 20%, 94%);--modal-overlay-background-color:rgba(213, 220, 226, 0.7);--progress-background-color:hsl(205, 18%, 86%);--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button-inverse:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(115, 130, 140)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(198, 40, 40)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(56, 142, 60)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");color-scheme:light}@media only screen and (prefers-color-scheme:dark){:root:not([data-theme]){--background-color:#11191f;--color:hsl(205, 16%, 77%);--h1-color:hsl(205, 20%, 94%);--h2-color:#e1e6eb;--h3-color:hsl(205, 18%, 86%);--h4-color:#c8d1d8;--h5-color:hsl(205, 16%, 77%);--h6-color:#afbbc4;--muted-color:hsl(205, 10%, 50%);--muted-border-color:#1f2d38;--primary:hsl(195, 85%, 41%);--primary-hover:hsl(195, 80%, 50%);--primary-focus:rgba(16, 149, 193, 0.25);--primary-inverse:#fff;--secondary:hsl(205, 15%, 41%);--secondary-hover:hsl(205, 10%, 50%);--secondary-focus:rgba(115, 130, 140, 0.25);--secondary-inverse:#fff;--contrast:hsl(205, 20%, 94%);--contrast-hover:#fff;--contrast-focus:rgba(115, 130, 140, 0.25);--contrast-inverse:#000;--mark-background-color:#d1c284;--mark-color:#11191f;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:#11191f;--form-element-border-color:#374956;--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:var(--form-element-background-color);--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:hsl(205, 25%, 23%);--form-element-disabled-border-color:hsl(205, 20%, 32%);--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#b71c1c;--form-element-invalid-active-border-color:#c62828;--form-element-invalid-focus-color:rgba(198, 40, 40, 0.25);--form-element-valid-border-color:#2e7d32;--form-element-valid-active-border-color:#388e3c;--form-element-valid-focus-color:rgba(56, 142, 60, 0.25);--switch-background-color:#374956;--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:#24333e;--range-active-border-color:hsl(205, 25%, 23%);--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:rgba(115, 130, 140, 0.05);--code-background-color:#18232c;--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:hsl(330, 30%, 50%);--code-property-color:hsl(185, 30%, 50%);--code-value-color:hsl(40, 10%, 50%);--code-comment-color:#4d606d;--accordion-border-color:var(--muted-border-color);--accordion-active-summary-color:var(--primary);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:#141e26;--card-border-color:var(--card-background-color);--card-box-shadow:0.0145rem 0.029rem 0.174rem rgba(0, 0, 0, 0.01698),0.0335rem 0.067rem 0.402rem rgba(0, 0, 0, 0.024),0.0625rem 0.125rem 0.75rem rgba(0, 0, 0, 0.03),0.1125rem 0.225rem 1.35rem rgba(0, 0, 0, 0.036),0.2085rem 0.417rem 2.502rem rgba(0, 0, 0, 0.04302),0.5rem 1rem 6rem rgba(0, 0, 0, 0.06),0 0 0 0.0625rem rgba(0, 0, 0, 0.015);--card-sectionning-background-color:#18232c;--dropdown-background-color:hsl(205, 30%, 15%);--dropdown-border-color:#24333e;--dropdown-box-shadow:var(--card-box-shadow);--dropdown-color:var(--color);--dropdown-hover-background-color:rgba(36, 51, 62, 0.75);--modal-overlay-background-color:rgba(36, 51, 62, 0.8);--progress-background-color:#24333e;--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button-inverse:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(0, 0, 0)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(115, 130, 140)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(183, 28, 28)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(46, 125, 50)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");color-scheme:dark}}[data-theme=dark]{--background-color:#11191f;--color:hsl(205, 16%, 77%);--h1-color:hsl(205, 20%, 94%);--h2-color:#e1e6eb;--h3-color:hsl(205, 18%, 86%);--h4-color:#c8d1d8;--h5-color:hsl(205, 16%, 77%);--h6-color:#afbbc4;--muted-color:hsl(205, 10%, 50%);--muted-border-color:#1f2d38;--primary:hsl(195, 85%, 41%);--primary-hover:hsl(195, 80%, 50%);--primary-focus:rgba(16, 149, 193, 0.25);--primary-inverse:#fff;--secondary:hsl(205, 15%, 41%);--secondary-hover:hsl(205, 10%, 50%);--secondary-focus:rgba(115, 130, 140, 0.25);--secondary-inverse:#fff;--contrast:hsl(205, 20%, 94%);--contrast-hover:#fff;--contrast-focus:rgba(115, 130, 140, 0.25);--contrast-inverse:#000;--mark-background-color:#d1c284;--mark-color:#11191f;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:#11191f;--form-element-border-color:#374956;--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:var(--form-element-background-color);--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:hsl(205, 25%, 23%);--form-element-disabled-border-color:hsl(205, 20%, 32%);--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#b71c1c;--form-element-invalid-active-border-color:#c62828;--form-element-invalid-focus-color:rgba(198, 40, 40, 0.25);--form-element-valid-border-color:#2e7d32;--form-element-valid-active-border-color:#388e3c;--form-element-valid-focus-color:rgba(56, 142, 60, 0.25);--switch-background-color:#374956;--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:#24333e;--range-active-border-color:hsl(205, 25%, 23%);--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:rgba(115, 130, 140, 0.05);--code-background-color:#18232c;--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:hsl(330, 30%, 50%);--code-property-color:hsl(185, 30%, 50%);--code-value-color:hsl(40, 10%, 50%);--code-comment-color:#4d606d;--accordion-border-color:var(--muted-border-color);--accordion-active-summary-color:var(--primary);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:#141e26;--card-border-color:var(--card-background-color);--card-box-shadow:0.0145rem 0.029rem 0.174rem rgba(0, 0, 0, 0.01698),0.0335rem 0.067rem 0.402rem rgba(0, 0, 0, 0.024),0.0625rem 0.125rem 0.75rem rgba(0, 0, 0, 0.03),0.1125rem 0.225rem 1.35rem rgba(0, 0, 0, 0.036),0.2085rem 0.417rem 2.502rem rgba(0, 0, 0, 0.04302),0.5rem 1rem 6rem rgba(0, 0, 0, 0.06),0 0 0 0.0625rem rgba(0, 0, 0, 0.015);--card-sectionning-background-color:#18232c;--dropdown-background-color:hsl(205, 30%, 15%);--dropdown-border-color:#24333e;--dropdown-box-shadow:var(--card-box-shadow);--dropdown-color:var(--color);--dropdown-hover-background-color:rgba(36, 51, 62, 0.75);--modal-overlay-background-color:rgba(36, 51, 62, 0.8);--progress-background-color:#24333e;--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button-inverse:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(0, 0, 0)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(115, 130, 140)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(183, 28, 28)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(46, 125, 50)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");color-scheme:dark}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--background-color);color:var(--color);font-weight:var(--font-weight);font-size:var(--font-size);line-height:var(--line-height);font-family:var(--font-family);text-rendering:optimizeLegibility;overflow-wrap:break-word;cursor:default;-moz-tab-size:4;-o-tab-size:4;tab-size:4}main{display:block}body{width:100%;margin:0}body>footer,body>header,body>main{width:100%;margin-right:auto;margin-left:auto;padding:var(--block-spacing-vertical) 0}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--spacing);padding-left:var(--spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:992px){.container{max-width:920px}}@media (min-width:1200px){.container{max-width:1130px}}section{margin-bottom:var(--block-spacing-vertical)}.grid{grid-column-gap:var(--grid-spacing-horizontal);grid-row-gap:var(--grid-spacing-vertical);display:grid;grid-template-columns:1fr;margin:0}@media (min-width:992px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}figure{display:block;margin:0;padding:0;overflow-x:auto}figure figcaption{padding:calc(var(--spacing) * .5) 0;color:var(--muted-color)}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,figure,form,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--typography-spacing-vertical);color:var(--color);font-style:normal;font-weight:var(--font-weight);font-size:var(--font-size)}[role=link],a{--color:var(--primary);--background-color:transparent;outline:0;background-color:var(--background-color);color:var(--color);-webkit-text-decoration:var(--text-decoration);text-decoration:var(--text-decoration);transition:background-color var(--transition),color var(--transition),box-shadow var(--transition),-webkit-text-decoration var(--transition);transition:background-color var(--transition),color var(--transition),text-decoration var(--transition),box-shadow var(--transition);transition:background-color var(--transition),color var(--transition),text-decoration var(--transition),box-shadow var(--transition),-webkit-text-decoration var(--transition)}[role=link]:is([aria-current],:hover,:active,:focus),a:is([aria-current],:hover,:active,:focus){--color:var(--primary-hover);--text-decoration:underline}[role=link]:focus,a:focus{--background-color:var(--primary-focus)}[role=link].secondary,a.secondary{--color:var(--secondary)}[role=link].secondary:is([aria-current],:hover,:active,:focus),a.secondary:is([aria-current],:hover,:active,:focus){--color:var(--secondary-hover)}[role=link].secondary:focus,a.secondary:focus{--background-color:var(--secondary-focus)}[role=link].contrast,a.contrast{--color:var(--contrast)}[role=link].contrast:is([aria-current],:hover,:active,:focus),a.contrast:is([aria-current],:hover,:active,:focus){--color:var(--contrast-hover)}[role=link].contrast:focus,a.contrast:focus{--background-color:var(--contrast-focus)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--typography-spacing-vertical);color:var(--color);font-weight:var(--font-weight);font-size:var(--font-size);font-family:var(--font-family)}h1{--color:var(--h1-color)}h2{--color:var(--h2-color)}h3{--color:var(--h3-color)}h4{--color:var(--h4-color)}h5{--color:var(--h5-color)}h6{--color:var(--h6-color)}:where(address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--typography-spacing-vertical)}.headings,hgroup{margin-bottom:var(--typography-spacing-vertical)}.headings>*,hgroup>*{margin-bottom:0}.headings>:last-child,hgroup>:last-child{--color:var(--muted-color);--font-weight:unset;font-size:1rem;font-family:unset}p{margin-bottom:var(--typography-spacing-vertical)}small{font-size:var(--font-size)}:where(dl,ol,ul){padding-right:0;padding-left:var(--spacing);-webkit-padding-start:var(--spacing);padding-inline-start:var(--spacing);-webkit-padding-end:0;padding-inline-end:0}:where(dl,ol,ul) li{margin-bottom:calc(var(--typography-spacing-vertical) * .25)}:where(dl,ol,ul) :is(dl,ol,ul){margin:0;margin-top:calc(var(--typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--mark-background-color);color:var(--mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--typography-spacing-vertical) 0;padding:var(--spacing);border-right:none;border-left:.25rem solid var(--blockquote-border-color);-webkit-border-start:0.25rem solid var(--blockquote-border-color);border-inline-start:0.25rem solid var(--blockquote-border-color);-webkit-border-end:none;border-inline-end:none}blockquote footer{margin-top:calc(var(--typography-spacing-vertical) * .5);color:var(--blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--ins-color);text-decoration:none}del{color:var(--del-color)}::-moz-selection{background-color:var(--primary-focus)}::selection{background-color:var(--primary-focus)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:root){overflow:hidden}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}button{display:block;width:100%;margin-bottom:var(--spacing)}[role=button]{display:inline-block;text-decoration:none}[role=button],button,input[type=button],input[type=reset],input[type=submit]{--background-color:var(--primary);--border-color:var(--primary);--color:var(--primary-inverse);--box-shadow:var(--button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[role=button]:is([aria-current],:hover,:active,:focus),button:is([aria-current],:hover,:active,:focus),input[type=button]:is([aria-current],:hover,:active,:focus),input[type=reset]:is([aria-current],:hover,:active,:focus),input[type=submit]:is([aria-current],:hover,:active,:focus){--background-color:var(--primary-hover);--border-color:var(--primary-hover);--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--color:var(--primary-inverse)}[role=button]:focus,button:focus,input[type=button]:focus,input[type=reset]:focus,input[type=submit]:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--primary-focus)}:is(button,input[type=submit],input[type=button],[role=button]).secondary,input[type=reset]{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);cursor:pointer}:is(button,input[type=submit],input[type=button],[role=button]).secondary:is([aria-current],:hover,:active,:focus),input[type=reset]:is([aria-current],:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover);--color:var(--secondary-inverse)}:is(button,input[type=submit],input[type=button],[role=button]).secondary:focus,input[type=reset]:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--secondary-focus)}:is(button,input[type=submit],input[type=button],[role=button]).contrast{--background-color:var(--contrast);--border-color:var(--contrast);--color:var(--contrast-inverse)}:is(button,input[type=submit],input[type=button],[role=button]).contrast:is([aria-current],:hover,:active,:focus){--background-color:var(--contrast-hover);--border-color:var(--contrast-hover);--color:var(--contrast-inverse)}:is(button,input[type=submit],input[type=button],[role=button]).contrast:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--contrast-focus)}:is(button,input[type=submit],input[type=button],[role=button]).outline,input[type=reset].outline{--background-color:transparent;--color:var(--primary)}:is(button,input[type=submit],input[type=button],[role=button]).outline:is([aria-current],:hover,:active,:focus),input[type=reset].outline:is([aria-current],:hover,:active,:focus){--background-color:transparent;--color:var(--primary-hover)}:is(button,input[type=submit],input[type=button],[role=button]).outline.secondary,input[type=reset].outline{--color:var(--secondary)}:is(button,input[type=submit],input[type=button],[role=button]).outline.secondary:is([aria-current],:hover,:active,:focus),input[type=reset].outline:is([aria-current],:hover,:active,:focus){--color:var(--secondary-hover)}:is(button,input[type=submit],input[type=button],[role=button]).outline.contrast{--color:var(--contrast)}:is(button,input[type=submit],input[type=button],[role=button]).outline.contrast:is([aria-current],:hover,:active,:focus){--color:var(--contrast-hover)}:where(button,[type=submit],[type=button],[type=reset],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]),a[role=button]:not([href]){opacity:.5;pointer-events:none}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--line-height) + var(--form-element-spacing-vertical) * 2 + var(--border-width) * 2)}fieldset{margin:0;margin-bottom:var(--spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--spacing) * .25);font-weight:var(--form-label-font-weight,var(--font-weight))}input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal)}input,select,textarea{--background-color:var(--form-element-background-color);--border-color:var(--form-element-border-color);--color:var(--form-element-color);--box-shadow:none;border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}:where(select,textarea):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--background-color:var(--form-element-active-background-color)}:where(select,textarea):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--border-color:var(--form-element-active-border-color)}input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus,select:focus,textarea:focus{--box-shadow:0 0 0 var(--outline-width) var(--form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],select[disabled],textarea[disabled]{--background-color:var(--form-element-disabled-background-color);--border-color:var(--form-element-disabled-border-color);opacity:var(--form-element-disabled-opacity);pointer-events:none}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week])[aria-invalid]{padding-right:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--form-element-spacing-horizontal);-webkit-padding-start:var(--form-element-spacing-horizontal)!important;padding-inline-start:var(--form-element-spacing-horizontal)!important;-webkit-padding-end:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;padding-inline-end:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week])[aria-invalid=false]{background-image:var(--icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week])[aria-invalid=true]{background-image:var(--icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--border-color:var(--form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--border-color:var(--form-element-valid-active-border-color)!important;--box-shadow:0 0 0 var(--outline-width) var(--form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--border-color:var(--form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--border-color:var(--form-element-invalid-active-border-color)!important;--box-shadow:0 0 0 var(--outline-width) var(--form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--form-element-spacing-horizontal) + 1.5rem);padding-left:var(--form-element-spacing-horizontal);-webkit-padding-start:var(--form-element-spacing-horizontal);padding-inline-start:var(--form-element-spacing-horizontal);-webkit-padding-end:calc(var(--form-element-spacing-horizontal) + 1.5rem);padding-inline-end:calc(var(--form-element-spacing-horizontal) + 1.5rem);background-image:var(--icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}:where(input,select,textarea,.grid)+small{display:block;width:100%;margin-top:calc(var(--spacing) * -.75);margin-bottom:var(--spacing);color:var(--muted-color)}label>:where(input,select,textarea){margin-top:calc(var(--spacing) * .25)}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-right:.375em;margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:.375em;margin-inline-end:.375em;border-width:var(--border-width);font-size:inherit;vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--background-color:var(--primary);--border-color:var(--primary);background-image:var(--icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-right:.375em;margin-bottom:0;cursor:pointer}[type=checkbox]:indeterminate{--background-color:var(--primary);--border-color:var(--primary);background-image:var(--icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--background-color:var(--primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--background-color:var(--switch-background-color);--border-color:var(--switch-background-color);--color:var(--switch-color);width:2.25em;height:1.25em;border:var(--border-width) solid var(--border-color);border-radius:1.25em;background-color:var(--background-color);line-height:1.25em}[type=checkbox][role=switch]:focus{--background-color:var(--switch-background-color);--border-color:var(--switch-background-color)}[type=checkbox][role=switch]:checked{--background-color:var(--switch-checked-background-color);--border-color:var(--switch-checked-background-color)}[type=checkbox][role=switch]:before{display:block;width:calc(1.25em - (var(--border-width) * 2));height:100%;border-radius:50%;background-color:var(--color);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:checked{background-image:none}[type=checkbox][role=switch]:checked::before{margin-left:calc(1.125em - var(--border-width));-webkit-margin-start:calc(1.125em - var(--border-width));margin-inline-start:calc(1.125em - var(--border-width))}[type=checkbox]:checked[aria-invalid=false],[type=checkbox][aria-invalid=false],[type=checkbox][role=switch]:checked[aria-invalid=false],[type=checkbox][role=switch][aria-invalid=false],[type=radio]:checked[aria-invalid=false],[type=radio][aria-invalid=false]{--border-color:var(--form-element-valid-border-color)}[type=checkbox]:checked[aria-invalid=true],[type=checkbox][aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=checkbox][role=switch][aria-invalid=true],[type=radio]:checked[aria-invalid=true],[type=radio][aria-invalid=true]{--border-color:var(--form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--icon-position:0.75rem;--icon-width:1rem;padding-right:calc(var(--icon-width) + var(--icon-position));background-image:var(--icon-date);background-position:center right var(--icon-position);background-size:var(--icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--icon-width);margin-right:calc(var(--icon-width) * -1);margin-left:var(--icon-position);opacity:0}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--color:var(--muted-color);padding:calc(var(--form-element-spacing-vertical) * .5) 0;border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing)/ 2);margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::file-selector-button:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=file]::-webkit-file-upload-button{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing)/ 2);margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;-webkit-transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::-webkit-file-upload-button:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=file]::-ms-browse{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;margin-inline-start:0;margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;-ms-transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::-ms-browse:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-webkit-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-moz-range-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-moz-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-ms-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-ms-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-moz-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-ms-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]:focus,[type=range]:hover{--range-border-color:var(--range-active-border-color);--range-thumb-color:var(--range-thumb-hover-color)}[type=range]:active{--range-thumb-color:var(--range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{-webkit-padding-start:calc(var(--form-element-spacing-horizontal) + 1.75rem);padding-inline-start:calc(var(--form-element-spacing-horizontal) + 1.75rem);border-radius:5rem;background-image:var(--icon-search);background-position:center left 1.125rem;background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{-webkit-padding-start:calc(var(--form-element-spacing-horizontal) + 1.75rem)!important;padding-inline-start:calc(var(--form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--icon-search),var(--icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--icon-search),var(--icon-invalid)}[type=search]::-webkit-search-cancel-button{-webkit-appearance:none;display:none}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--spacing)/ 2) var(--spacing);border-bottom:var(--border-width) solid var(--table-border-color);color:var(--color);font-weight:var(--font-weight);font-size:var(--font-size);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--border-width) solid var(--table-border-color);border-bottom:0}table[role=grid] tbody tr:nth-child(odd){background-color:var(--table-row-stripped-background-color)}code,kbd,pre,samp{font-size:.875em;font-family:var(--font-family)}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre{border-radius:var(--border-radius);background:var(--code-background-color);color:var(--code-color);font-weight:var(--font-weight);line-height:initial}code,kbd{display:inline-block;padding:.375rem .5rem}pre{display:block;margin-bottom:var(--spacing);overflow-x:auto}pre>code{display:block;padding:var(--spacing);background:0 0;font-size:14px;line-height:var(--line-height)}code b{color:var(--code-tag-color);font-weight:var(--font-weight)}code i{color:var(--code-property-color);font-style:normal}code u{color:var(--code-value-color);text-decoration:none}code em{color:var(--code-comment-color);font-style:normal}kbd{background-color:var(--code-kbd-background-color);color:var(--code-kbd-color);vertical-align:baseline}hr{height:0;border:0;border-top:1px solid var(--muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}details{display:block;margin-bottom:var(--spacing);padding-bottom:var(--spacing);border-bottom:var(--border-width) solid var(--accordion-border-color)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--transition)}details summary:not([role]){color:var(--accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;-webkit-margin-start:calc(var(--spacing,1rem) * 0.5);margin-inline-start:calc(var(--spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--transition)}details summary:focus{outline:0}details summary:focus:not([role=button]){color:var(--accordion-active-summary-color)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--line-height,1.5));background-image:var(--icon-chevron-button)}details summary[role=button]:not(.outline).contrast::after{background-image:var(--icon-chevron-button-inverse)}details[open]>summary{margin-bottom:calc(var(--spacing))}details[open]>summary:not([role]):not(:focus){color:var(--accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin:var(--block-spacing-vertical) 0;padding:var(--block-spacing-vertical) var(--block-spacing-horizontal);border-radius:var(--border-radius);background:var(--card-background-color);box-shadow:var(--card-box-shadow)}article>footer,article>header{margin-right:calc(var(--block-spacing-horizontal) * -1);margin-left:calc(var(--block-spacing-horizontal) * -1);padding:calc(var(--block-spacing-vertical) * .66) var(--block-spacing-horizontal);background-color:var(--card-sectionning-background-color)}article>header{margin-top:calc(var(--block-spacing-vertical) * -1);margin-bottom:var(--block-spacing-vertical);border-bottom:var(--border-width) solid var(--card-border-color);border-top-right-radius:var(--border-radius);border-top-left-radius:var(--border-radius)}article>footer{margin-top:var(--block-spacing-vertical);margin-bottom:calc(var(--block-spacing-vertical) * -1);border-top:var(--border-width) solid var(--card-border-color);border-bottom-right-radius:var(--border-radius);border-bottom-left-radius:var(--border-radius)}:root{--scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:var(--spacing);border:0;-webkit-backdrop-filter:var(--modal-overlay-backdrop-filter);backdrop-filter:var(--modal-overlay-backdrop-filter);background-color:var(--modal-overlay-background-color);color:var(--color)}dialog article{max-height:calc(100vh - var(--spacing) * 2);overflow:auto}@media (min-width:576px){dialog article{max-width:510px}}@media (min-width:768px){dialog article{max-width:700px}}dialog article>footer,dialog article>header{padding:calc(var(--block-spacing-vertical) * .5) var(--block-spacing-horizontal)}dialog article>header .close{margin:0;margin-left:var(--spacing);float:right}dialog article>footer{text-align:right}dialog article>footer [role=button]{margin-bottom:0}dialog article>footer [role=button]:not(:first-of-type){margin-left:calc(var(--spacing) * .5)}dialog article p:last-of-type{margin:0}dialog article .close{display:block;width:1rem;height:1rem;margin-top:calc(var(--block-spacing-vertical) * -.5);margin-bottom:var(--typography-spacing-vertical);margin-left:auto;background-image:var(--icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;opacity:.5;transition:opacity var(--transition)}dialog article .close:is([aria-current],:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--nav-element-spacing-vertical) var(--nav-element-spacing-horizontal)}nav li>*{--spacing:0}nav :where(a,[role=link]){display:inline-block;margin:calc(var(--nav-link-spacing-vertical) * -1) calc(var(--nav-link-spacing-horizontal) * -1);padding:var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal);border-radius:var(--border-radius);text-decoration:none}nav :where(a,[role=link]):is([aria-current],:hover,:active,:focus){text-decoration:none}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){-webkit-margin-start:var(--nav-link-spacing-horizontal);margin-inline-start:var(--nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{position:absolute;width:calc(var(--nav-link-spacing-horizontal) * 2);-webkit-margin-start:calc(var(--nav-link-spacing-horizontal)/ 2);margin-inline-start:calc(var(--nav-link-spacing-horizontal)/ 2);content:"/";color:var(--muted-color);text-align:center}nav[aria-label=breadcrumb] a[aria-current]{background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}nav [role=button]{margin-right:inherit;margin-left:inherit;padding:var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal)}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--nav-element-spacing-vertical) * .5) var(--nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--spacing) * .5);overflow:hidden;border:0;border-radius:var(--border-radius);background-color:var(--progress-background-color);color:var(--progress-color)}progress::-webkit-progress-bar{border-radius:var(--border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--progress-color)}progress::-moz-progress-bar{background-color:var(--progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--progress-background-color) linear-gradient(to right,var(--progress-color) 30%,var(--progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}details[role=list],li[role=list]{position:relative}details[role=list] summary+ul,li[role=list]>ul{display:flex;z-index:99;position:absolute;top:auto;right:0;left:0;flex-direction:column;margin:0;padding:0;border:var(--border-width) solid var(--dropdown-border-color);border-radius:var(--border-radius);border-top-right-radius:0;border-top-left-radius:0;background-color:var(--dropdown-background-color);box-shadow:var(--card-box-shadow);color:var(--dropdown-color);white-space:nowrap}details[role=list] summary+ul li,li[role=list]>ul li{width:100%;margin-bottom:0;padding:calc(var(--form-element-spacing-vertical) * .5) var(--form-element-spacing-horizontal);list-style:none}details[role=list] summary+ul li:first-of-type,li[role=list]>ul li:first-of-type{margin-top:calc(var(--form-element-spacing-vertical) * .5)}details[role=list] summary+ul li:last-of-type,li[role=list]>ul li:last-of-type{margin-bottom:calc(var(--form-element-spacing-vertical) * .5)}details[role=list] summary+ul li a,li[role=list]>ul li a{display:block;margin:calc(var(--form-element-spacing-vertical) * -.5) calc(var(--form-element-spacing-horizontal) * -1);padding:calc(var(--form-element-spacing-vertical) * .5) var(--form-element-spacing-horizontal);overflow:hidden;color:var(--dropdown-color);text-decoration:none;text-overflow:ellipsis}details[role=list] summary+ul li a:hover,li[role=list]>ul li a:hover{background-color:var(--dropdown-hover-background-color)}details[role=list] summary::after,li[role=list]>a::after{display:block;width:1rem;height:calc(1rem * var(--line-height,1.5));-webkit-margin-start:0.5rem;margin-inline-start:.5rem;float:right;transform:rotate(0);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}details[role=list]{padding:0;border-bottom:none}details[role=list] summary{margin-bottom:0}details[role=list] summary:not([role]){height:calc(1rem * var(--line-height) + var(--form-element-spacing-vertical) * 2 + var(--border-width) * 2);padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal);border:var(--border-width) solid var(--form-element-border-color);border-radius:var(--border-radius);background-color:var(--form-element-background-color);color:var(--form-element-placeholder-color);line-height:inherit;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}details[role=list] summary:not([role]):active,details[role=list] summary:not([role]):focus{border-color:var(--form-element-active-border-color);background-color:var(--form-element-active-background-color)}details[role=list] summary:not([role]):focus{box-shadow:0 0 0 var(--outline-width) var(--form-element-focus-color)}details[role=list][open] summary{border-bottom-right-radius:0;border-bottom-left-radius:0}details[role=list][open] summary::before{display:block;z-index:1;position:fixed;top:0;right:0;bottom:0;left:0;background:0 0;content:"";cursor:default}nav details[role=list] summary,nav li[role=list] a{display:flex;direction:ltr}nav details[role=list] summary+ul,nav li[role=list]>ul{min-width:-moz-fit-content;min-width:fit-content;border-radius:var(--border-radius)}nav details[role=list] summary+ul li a,nav li[role=list]>ul li a{border-radius:0}nav details[role=list] summary,nav details[role=list] summary:not([role]){height:auto;padding:var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal)}nav details[role=list][open] summary{border-radius:var(--border-radius)}nav details[role=list] summary+ul{margin-top:var(--outline-width);-webkit-margin-start:0;margin-inline-start:0}nav details[role=list] summary[role=link]{margin-bottom:calc(var(--nav-link-spacing-vertical) * -1);line-height:var(--line-height)}nav details[role=list] summary[role=link]+ul{margin-top:calc(var(--nav-link-spacing-vertical) + var(--outline-width));-webkit-margin-start:calc(var(--nav-link-spacing-horizontal) * -1);margin-inline-start:calc(var(--nav-link-spacing-horizontal) * -1)}li[role=list] a:active~ul,li[role=list] a:focus~ul,li[role=list]:hover>ul{display:flex}li[role=list]>ul{display:none;margin-top:calc(var(--nav-link-spacing-vertical) + var(--outline-width));-webkit-margin-start:calc(var(--nav-element-spacing-horizontal) - var(--nav-link-spacing-horizontal));margin-inline-start:calc(var(--nav-element-spacing-horizontal) - var(--nav-link-spacing-horizontal))}li[role=list]>a::after{background-image:var(--icon-chevron)}label>details[role=list]{margin-top:calc(var(--spacing) * .25);margin-bottom:var(--spacing)}[aria-busy=true]{cursor:progress}[aria-busy=true]:not(input,select,textarea)::before{display:inline-block;width:1em;height:1em;border:.1875em solid currentColor;border-radius:1em;border-right-color:transparent;content:"";vertical-align:text-bottom;vertical-align:-.125em;animation:spinner .75s linear infinite;opacity:var(--loading-spinner-opacity)}[aria-busy=true]:not(input,select,textarea):not(:empty)::before{margin-right:calc(var(--spacing) * .5);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing) * .5);margin-inline-end:calc(var(--spacing) * .5)}[aria-busy=true]:not(input,select,textarea):empty{text-align:center}a[aria-busy=true],button[aria-busy=true],input[type=button][aria-busy=true],input[type=reset][aria-busy=true],input[type=submit][aria-busy=true]{pointer-events:none}@keyframes spinner{to{transform:rotate(360deg)}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--border-radius);background:var(--tooltip-background-color);content:attr(data-tooltip);color:var(--tooltip-color);font-style:normal;font-weight:var(--font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:hover::after,[data-tooltip]:hover::before,[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover [data-tooltip]:focus::after,[data-tooltip][data-placement=bottom]:hover [data-tooltip]:focus::before{animation-duration:.2s;animation-name:tooltip-slide-top}[data-tooltip]:hover::after,[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover [data-tooltip]:focus::after{animation-name:tooltip-caret-slide-top}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{animation-duration:.2s;animation-name:tooltip-slide-bottom}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{animation-name:tooltip-caret-slide-bottom}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{animation-duration:.2s;animation-name:tooltip-slide-left}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{animation-name:tooltip-caret-slide-left}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{animation-duration:.2s;animation-name:tooltip-slide-right}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{animation-name:tooltip-caret-slide-right}}@keyframes tooltip-slide-top{from{transform:translate(-50%,.75rem);opacity:0}to{transform:translate(-50%,-.25rem);opacity:1}}@keyframes tooltip-caret-slide-top{from{opacity:0}50%{transform:translate(-50%,-.25rem);opacity:0}to{transform:translate(-50%,0);opacity:1}}@keyframes tooltip-slide-bottom{from{transform:translate(-50%,-.75rem);opacity:0}to{transform:translate(-50%,.25rem);opacity:1}}@keyframes tooltip-caret-slide-bottom{from{opacity:0}50%{transform:translate(-50%,-.5rem);opacity:0}to{transform:translate(-50%,-.3rem);opacity:1}}@keyframes tooltip-slide-left{from{transform:translate(.75rem,-50%);opacity:0}to{transform:translate(-.25rem,-50%);opacity:1}}@keyframes tooltip-caret-slide-left{from{opacity:0}50%{transform:translate(.05rem,-50%);opacity:0}to{transform:translate(.3rem,-50%);opacity:1}}@keyframes tooltip-slide-right{from{transform:translate(-.75rem,-50%);opacity:0}to{transform:translate(.25rem,-50%);opacity:1}}@keyframes tooltip-caret-slide-right{from{opacity:0}50%{transform:translate(-.05rem,-50%);opacity:0}to{transform:translate(-.3rem,-50%);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}} +/*# sourceMappingURL=pico.min.css.map */ \ No newline at end of file diff --git a/web/utils.go b/web/utils.go new file mode 100644 index 0000000..818b1f9 --- /dev/null +++ b/web/utils.go @@ -0,0 +1,16 @@ +package web + +import ( + "owl-blogs/app/repository" + "owl-blogs/config" + "owl-blogs/domain/model" +) + +func getSiteConfig(repo repository.ConfigRepository) model.SiteConfig { + siteConfig := model.SiteConfig{} + err := repo.Get(config.SITE_CONFIG, &siteConfig) + if err != nil { + panic(err) + } + return siteConfig +} diff --git a/webmention.go b/webmention.go deleted file mode 100644 index e0e0f94..0000000 --- a/webmention.go +++ /dev/null @@ -1,43 +0,0 @@ -package owl - -import ( - "time" -) - -type WebmentionIn struct { - Source string `yaml:"source"` - Title string `yaml:"title"` - ApprovalStatus string `yaml:"approval_status"` - RetrievedAt time.Time `yaml:"retrieved_at"` -} - -func (webmention *WebmentionIn) UpdateWith(update WebmentionIn) { - if update.Title != "" { - webmention.Title = update.Title - } - if update.ApprovalStatus != "" { - webmention.ApprovalStatus = update.ApprovalStatus - } - if !update.RetrievedAt.IsZero() { - webmention.RetrievedAt = update.RetrievedAt - } -} - -type WebmentionOut struct { - Target string `yaml:"target"` - Supported bool `yaml:"supported"` - ScannedAt time.Time `yaml:"scanned_at"` - LastSentAt time.Time `yaml:"last_sent_at"` -} - -func (webmention *WebmentionOut) UpdateWith(update WebmentionOut) { - if update.Supported { - webmention.Supported = update.Supported - } - if !update.ScannedAt.IsZero() { - webmention.ScannedAt = update.ScannedAt - } - if !update.LastSentAt.IsZero() { - webmention.LastSentAt = update.LastSentAt - } -} diff --git a/webmention_test.go b/webmention_test.go deleted file mode 100644 index 8de1420..0000000 --- a/webmention_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package owl_test - -import ( - "bytes" - "h4kor/owl-blogs" - "h4kor/owl-blogs/test/assertions" - "io" - "net/http" - "net/url" - "testing" -) - -func constructResponse(html []byte) *http.Response { - url, _ := url.Parse("http://example.com/foo/bar") - return &http.Response{ - Request: &http.Request{ - URL: url, - }, - Body: io.NopCloser(bytes.NewReader([]byte(html))), - } -} - -// -// https://www.w3.org/TR/webmention/#h-webmention-verification -// - -func TestParseValidHEntry(t *testing.T) { - html := []byte("
      Foo
      ") - parser := &owl.OwlHtmlParser{} - entry, err := parser.ParseHEntry(&http.Response{Body: io.NopCloser(bytes.NewReader(html))}) - - assertions.AssertNoError(t, err, "Unable to parse feed") - assertions.AssertEqual(t, entry.Title, "Foo") -} - -func TestParseValidHEntryWithoutTitle(t *testing.T) { - html := []byte("
      Foo
      ") - parser := &owl.OwlHtmlParser{} - entry, err := parser.ParseHEntry(&http.Response{Body: io.NopCloser(bytes.NewReader(html))}) - - assertions.AssertNoError(t, err, "Unable to parse feed") - assertions.AssertEqual(t, entry.Title, "") -} - -func TestGetWebmentionEndpointLink(t *testing.T) { - html := []byte("") - parser := &owl.OwlHtmlParser{} - endpoint, err := parser.GetWebmentionEndpoint(constructResponse(html)) - - assertions.AssertNoError(t, err, "Unable to parse feed") - - assertions.AssertEqual(t, endpoint, "http://example.com/webmention") -} - -func TestGetWebmentionEndpointLinkA(t *testing.T) { - html := []byte("") - parser := &owl.OwlHtmlParser{} - endpoint, err := parser.GetWebmentionEndpoint(constructResponse(html)) - - assertions.AssertNoError(t, err, "Unable to parse feed") - assertions.AssertEqual(t, endpoint, "http://example.com/webmention") -} - -func TestGetWebmentionEndpointLinkAFakeWebmention(t *testing.T) { - html := []byte("") - parser := &owl.OwlHtmlParser{} - endpoint, err := parser.GetWebmentionEndpoint(constructResponse(html)) - - assertions.AssertNoError(t, err, "Unable to parse feed") - assertions.AssertEqual(t, endpoint, "http://example.com/webmention") -} - -func TestGetWebmentionEndpointLinkHeader(t *testing.T) { - html := []byte("") - parser := &owl.OwlHtmlParser{} - resp := constructResponse(html) - resp.Header = http.Header{"Link": []string{"; rel=\"webmention\""}} - endpoint, err := parser.GetWebmentionEndpoint(resp) - - assertions.AssertNoError(t, err, "Unable to parse feed") - assertions.AssertEqual(t, endpoint, "http://example.com/webmention") -} - -func TestGetWebmentionEndpointLinkHeaderCommas(t *testing.T) { - html := []byte("") - parser := &owl.OwlHtmlParser{} - resp := constructResponse(html) - resp.Header = http.Header{ - "Link": []string{"; rel=\"other\", ; rel=\"webmention\""}, - } - endpoint, err := parser.GetWebmentionEndpoint(resp) - - assertions.AssertNoError(t, err, "Unable to parse feed") - assertions.AssertEqual(t, endpoint, "https://webmention.rocks/test/19/webmention") -} - -func TestGetWebmentionEndpointRelativeLink(t *testing.T) { - html := []byte("") - parser := &owl.OwlHtmlParser{} - endpoint, err := parser.GetWebmentionEndpoint(constructResponse(html)) - - assertions.AssertNoError(t, err, "Unable to parse feed") - assertions.AssertEqual(t, endpoint, "http://example.com/webmention") -} - -func TestGetWebmentionEndpointRelativeLinkInHeader(t *testing.T) { - html := []byte("") - parser := &owl.OwlHtmlParser{} - resp := constructResponse(html) - resp.Header = http.Header{"Link": []string{"; rel=\"webmention\""}} - endpoint, err := parser.GetWebmentionEndpoint(resp) - - assertions.AssertNoError(t, err, "Unable to parse feed") - assertions.AssertEqual(t, endpoint, "http://example.com/webmention") -} - -// func TestRealWorldWebmention(t *testing.T) { -// links := []string{ -// "https://webmention.rocks/test/1", -// "https://webmention.rocks/test/2", -// "https://webmention.rocks/test/3", -// "https://webmention.rocks/test/4", -// "https://webmention.rocks/test/5", -// "https://webmention.rocks/test/6", -// "https://webmention.rocks/test/7", -// "https://webmention.rocks/test/8", -// "https://webmention.rocks/test/9", -// // "https://webmention.rocks/test/10", // not supported -// "https://webmention.rocks/test/11", -// "https://webmention.rocks/test/12", -// "https://webmention.rocks/test/13", -// "https://webmention.rocks/test/14", -// "https://webmention.rocks/test/15", -// "https://webmention.rocks/test/16", -// "https://webmention.rocks/test/17", -// "https://webmention.rocks/test/18", -// "https://webmention.rocks/test/19", -// "https://webmention.rocks/test/20", -// "https://webmention.rocks/test/21", -// "https://webmention.rocks/test/22", -// "https://webmention.rocks/test/23/page", -// } - -// for _, link := range links { -// parser := &owl.OwlHtmlParser{} -// client := &owl.OwlHttpClient{} -// html, _ := client.Get(link) -// _, err := parser.GetWebmentionEndpoint(html) - -// if err != nil { -// t.Errorf("Unable to find webmention: %v for link %v", err, link) -// } -// } - -// }