Compare commits
12 Commits
eb331889bb
...
715445ee5d
Author | SHA1 | Date |
---|---|---|
Maxim Lebedev | 715445ee5d | |
Maxim Lebedev | 7a0727aa8d | |
Maxim Lebedev | 25494915ba | |
Maxim Lebedev | 1718bf6f7e | |
Maxim Lebedev | 733a3e4e8b | |
Maxim Lebedev | 665af5ebfc | |
Maxim Lebedev | 5adcb66862 | |
Maxim Lebedev | ed87027846 | |
Maxim Lebedev | fa2178e597 | |
Maxim Lebedev | c1bae19013 | |
Maxim Lebedev | b23aad0791 | |
Maxim Lebedev | 9422f13e7c |
|
@ -0,0 +1,23 @@
|
||||||
|
; https://EditorConfig.org/
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{css,js,html}]
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 8
|
||||||
|
indent_style = tab
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.golden]
|
||||||
|
insert_final_newline = false
|
||||||
|
trim_trailing_whitespace = false
|
|
@ -57,7 +57,7 @@ func NewApp(logger *log.Logger, config *domain.Config) (*App, error) {
|
||||||
themer := themeucase.NewThemeUseCase(partialsDir, themes)
|
themer := themeucase.NewThemeUseCase(partialsDir, themes)
|
||||||
pages := pagefsrepo.NewFileSystemPageRepository(contentDir)
|
pages := pagefsrepo.NewFileSystemPageRepository(contentDir)
|
||||||
pager := pageucase.NewPageUseCase(pages, resources)
|
pager := pageucase.NewPageUseCase(pages, resources)
|
||||||
server := servercase.NewServerUseCase()
|
serverer := servercase.NewServerUseCase()
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// INFO(toby3d): any static file is public and unprotected by design, so it's safe to search it
|
// INFO(toby3d): any static file is public and unprotected by design, so it's safe to search it
|
||||||
// first before deep down to any page or it's resource which might be secured by middleware or
|
// first before deep down to any page or it's resource which might be secured by middleware or
|
||||||
|
@ -77,43 +77,8 @@ func NewApp(logger *log.Logger, config *domain.Config) (*App, error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
siteServer, err := server.Do(r.Context(), *s)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range siteServer.Headers {
|
|
||||||
if !siteServer.Headers[i].IsMatched(r.URL.Path) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, value := range siteServer.Headers[i].Values {
|
|
||||||
w.Header().Add(name, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var redirect *domain.Redirect
|
|
||||||
for i := range siteServer.Redirects {
|
|
||||||
if !siteServer.Redirects[i].IsMatch(r.URL.Path) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if siteServer.Redirects[i].Force {
|
|
||||||
http.Redirect(w, r, siteServer.Redirects[i].To, siteServer.Redirects[i].Status)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect = &siteServer.Redirects[i]
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.IsMultiLingual() {
|
if s.IsMultiLingual() {
|
||||||
head, tail := urlutil.ShiftPath(r.URL.Path)
|
head, tail := urlutil.ShiftPath(r.URL.Path)
|
||||||
|
|
||||||
if head == "" {
|
if head == "" {
|
||||||
supported := make([]language.Tag, len(s.Languages))
|
supported := make([]language.Tag, len(s.Languages))
|
||||||
for i := range s.Languages {
|
for i := range s.Languages {
|
||||||
|
@ -125,8 +90,8 @@ func NewApp(logger *log.Logger, config *domain.Config) (*App, error) {
|
||||||
supported...)
|
supported...)
|
||||||
}
|
}
|
||||||
|
|
||||||
requested, _, err := language.ParseAcceptLanguage(
|
requested, _, err := language.ParseAcceptLanguage(r.Header.Get(
|
||||||
r.Header.Get(common.HeaderAcceptLanguage))
|
common.HeaderAcceptLanguage))
|
||||||
if err != nil || len(requested) == 0 {
|
if err != nil || len(requested) == 0 {
|
||||||
requested = append(requested, language.English)
|
requested = append(requested, language.English)
|
||||||
}
|
}
|
||||||
|
@ -139,14 +104,9 @@ func NewApp(logger *log.Logger, config *domain.Config) (*App, error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lang = domain.NewLanguage(head)
|
if lang = domain.NewLanguage(head); lang != domain.LanguageUnd {
|
||||||
r.URL.Path = tail
|
r.URL.Path = tail
|
||||||
}
|
}
|
||||||
|
|
||||||
if lang == domain.LanguageUnd && redirect != nil {
|
|
||||||
http.Redirect(w, r, redirect.To, redirect.Status)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if s, err = siter.Do(r.Context(), lang); err != nil {
|
if s, err = siter.Do(r.Context(), lang); err != nil {
|
||||||
|
@ -155,38 +115,6 @@ func NewApp(logger *log.Logger, config *domain.Config) (*App, error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if siteServer, err = server.Do(r.Context(), *s); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range siteServer.Headers {
|
|
||||||
if !siteServer.Headers[i].IsMatched(r.URL.Path) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, value := range siteServer.Headers[i].Values {
|
|
||||||
w.Header().Add(name, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range siteServer.Redirects {
|
|
||||||
if !siteServer.Redirects[i].IsMatch(r.URL.Path) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if siteServer.Redirects[i].Force {
|
|
||||||
http.Redirect(w, r, siteServer.Redirects[i].To, siteServer.Redirects[i].Status)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect = &siteServer.Redirects[i]
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
p, err := pager.Do(r.Context(), lang, r.URL.Path)
|
p, err := pager.Do(r.Context(), lang, r.URL.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, page.ErrNotExist) {
|
if !errors.Is(err, page.ErrNotExist) {
|
||||||
|
@ -195,12 +123,6 @@ func NewApp(logger *log.Logger, config *domain.Config) (*App, error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if redirect != nil {
|
|
||||||
http.Redirect(w, r, redirect.To, redirect.Status)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := resourcer.Do(r.Context(), r.URL.Path)
|
res, err := resourcer.Do(r.Context(), r.URL.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
@ -255,7 +177,17 @@ func NewApp(logger *log.Logger, config *domain.Config) (*App, error) {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
chain := middleware.Chain{middleware.LogFmt()}
|
chain := middleware.Chain{
|
||||||
|
middleware.LogFmt(),
|
||||||
|
middleware.Redirect(middleware.RedirectConfig{
|
||||||
|
Siter: siter,
|
||||||
|
Serverer: serverer,
|
||||||
|
}),
|
||||||
|
middleware.Header(middleware.HeaderConfig{
|
||||||
|
Siter: siter,
|
||||||
|
Serverer: serverer,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
return &App{server: &http.Server{
|
return &App{server: &http.Server{
|
||||||
Addr: config.AddrPort().String(),
|
Addr: config.AddrPort().String(),
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import "source.toby3d.me/toby3d/home/internal/common"
|
||||||
|
|
||||||
|
type Direction struct{ direction string }
|
||||||
|
|
||||||
|
var (
|
||||||
|
DirectionUnd Direction = Direction{} // "und"
|
||||||
|
DirectionLeftToRight Direction = Direction{"ltr"} // "ltr"
|
||||||
|
DirectionRightToLeft Direction = Direction{"rtl"} // "rtl"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (d Direction) String() string {
|
||||||
|
if d.direction == "" {
|
||||||
|
return common.Und
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.direction
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Direction) GoString() string {
|
||||||
|
return "domain.Direction(" + d.String() + ")"
|
||||||
|
}
|
|
@ -1,93 +0,0 @@
|
||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/md5"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type File struct {
|
|
||||||
Language Language
|
|
||||||
baseFileName string
|
|
||||||
contentBaseName string
|
|
||||||
dir string
|
|
||||||
ext string
|
|
||||||
filename string
|
|
||||||
logicalName string
|
|
||||||
path string
|
|
||||||
translationBaseName string
|
|
||||||
uniqueId string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFile(path string) File {
|
|
||||||
out := File{
|
|
||||||
Language: LanguageUnd,
|
|
||||||
baseFileName: "",
|
|
||||||
contentBaseName: "",
|
|
||||||
dir: filepath.Dir(path) + string(filepath.Separator),
|
|
||||||
ext: strings.TrimPrefix(filepath.Ext(path), "."),
|
|
||||||
filename: path,
|
|
||||||
logicalName: filepath.Base(path),
|
|
||||||
path: path,
|
|
||||||
translationBaseName: "",
|
|
||||||
uniqueId: "",
|
|
||||||
}
|
|
||||||
out.baseFileName = strings.TrimSuffix(out.logicalName, filepath.Ext(out.logicalName))
|
|
||||||
|
|
||||||
parts := strings.Split(out.baseFileName, ".")
|
|
||||||
out.Language = NewLanguage(parts[len(parts)-1])
|
|
||||||
out.translationBaseName = strings.Join(parts[:len(parts)-1], ".")
|
|
||||||
out.contentBaseName = out.translationBaseName
|
|
||||||
|
|
||||||
switch out.translationBaseName {
|
|
||||||
default:
|
|
||||||
out.contentBaseName = out.translationBaseName
|
|
||||||
case "_index", "index":
|
|
||||||
out.contentBaseName = filepath.Base(out.dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
hash := md5.New()
|
|
||||||
_, _ = hash.Write([]byte(out.path))
|
|
||||||
out.uniqueId = string(hash.Sum(nil))
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// BaseFileName returns file name without extention.
|
|
||||||
func (f File) BaseFileName() string {
|
|
||||||
return f.baseFileName
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f File) ContentBaseName() string {
|
|
||||||
return f.contentBaseName
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dir returns directory path.
|
|
||||||
func (f File) Dir() string {
|
|
||||||
return f.dir
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ext returns file extention.
|
|
||||||
func (f File) Ext() string {
|
|
||||||
return f.ext
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f File) Filename() string {
|
|
||||||
return f.filename
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f File) LogicalName() string {
|
|
||||||
return f.logicalName
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f File) Path() string {
|
|
||||||
return f.path
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f File) TranslationBaseName() string {
|
|
||||||
return f.translationBaseName
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f File) UniqueID() string {
|
|
||||||
return f.uniqueId
|
|
||||||
}
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
type Headers []Header
|
||||||
|
|
||||||
|
func (h Headers) Match(p string) []Header {
|
||||||
|
result := make(Headers, 0, len(h))
|
||||||
|
|
||||||
|
for i := range h {
|
||||||
|
if !h[i].IsMatched(p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, h[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
|
@ -13,7 +13,7 @@ type Language struct {
|
||||||
code string
|
code string
|
||||||
lang string
|
lang string
|
||||||
name string
|
name string
|
||||||
dir string
|
dir Direction
|
||||||
}
|
}
|
||||||
|
|
||||||
var LanguageUnd Language = Language{} // "und"
|
var LanguageUnd Language = Language{} // "und"
|
||||||
|
@ -26,13 +26,13 @@ func NewLanguage(raw string) Language {
|
||||||
|
|
||||||
out := Language{
|
out := Language{
|
||||||
code: tag.String(),
|
code: tag.String(),
|
||||||
dir: "ltr",
|
dir: DirectionLeftToRight,
|
||||||
name: strings.ToLower(display.Self.Name(tag)),
|
name: strings.ToLower(display.Self.Name(tag)),
|
||||||
}
|
}
|
||||||
|
|
||||||
switch tag {
|
switch tag {
|
||||||
case language.Arabic, language.Persian, language.Hebrew, language.Urdu:
|
case language.Arabic, language.Persian, language.Hebrew, language.Urdu:
|
||||||
out.dir = "rtl"
|
out.dir = DirectionRightToLeft
|
||||||
}
|
}
|
||||||
|
|
||||||
base, _ := tag.Base()
|
base, _ := tag.Base()
|
||||||
|
@ -49,7 +49,7 @@ func (l Language) Code() string {
|
||||||
return l.code
|
return l.code
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l Language) Dir() string {
|
func (l Language) Dir() Direction {
|
||||||
return l.dir
|
return l.dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,7 +58,7 @@ func (l Language) Name() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l Language) String() string {
|
func (l Language) String() string {
|
||||||
if l.code == "" {
|
if l.code != "" {
|
||||||
return l.code
|
return l.code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -60,13 +60,13 @@ func TestLanguage_Dir(t *testing.T) {
|
||||||
|
|
||||||
for name, tc := range map[string]struct {
|
for name, tc := range map[string]struct {
|
||||||
input string
|
input string
|
||||||
expect string
|
expect domain.Direction
|
||||||
}{
|
}{
|
||||||
"2letter": {"en", "ltr"},
|
"2letter": {"en", domain.DirectionLeftToRight},
|
||||||
"rtl": {"ur", "rtl"},
|
"rtl": {"ur", domain.DirectionRightToLeft},
|
||||||
"3letter": {"eng", "ltr"},
|
"3letter": {"eng", domain.DirectionLeftToRight},
|
||||||
"region": {"en-US", "ltr"},
|
"region": {"en-US", domain.DirectionLeftToRight},
|
||||||
common.Und: {"", ""},
|
common.Und: {"", domain.DirectionUnd},
|
||||||
} {
|
} {
|
||||||
name, tc := name, tc
|
name, tc := name, tc
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ package domain
|
||||||
type Page struct {
|
type Page struct {
|
||||||
Language Language
|
Language Language
|
||||||
Params map[string]any
|
Params map[string]any
|
||||||
File File
|
File Path
|
||||||
Description string
|
Description string
|
||||||
Title string
|
Title string
|
||||||
Content []byte
|
Content []byte
|
||||||
|
|
|
@ -0,0 +1,126 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Path struct {
|
||||||
|
Language Language
|
||||||
|
baseFileName string
|
||||||
|
contentBaseName string
|
||||||
|
dir string
|
||||||
|
ext string
|
||||||
|
filename string
|
||||||
|
logicalName string
|
||||||
|
path string
|
||||||
|
translationBaseName string
|
||||||
|
uniqueId string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPath(path string) Path {
|
||||||
|
out := Path{
|
||||||
|
Language: LanguageUnd,
|
||||||
|
baseFileName: "",
|
||||||
|
contentBaseName: "",
|
||||||
|
dir: filepath.Dir(path),
|
||||||
|
ext: strings.TrimPrefix(filepath.Ext(path), "."),
|
||||||
|
filename: path,
|
||||||
|
logicalName: filepath.Base(path),
|
||||||
|
path: path,
|
||||||
|
translationBaseName: "",
|
||||||
|
uniqueId: "",
|
||||||
|
}
|
||||||
|
out.baseFileName = strings.TrimSuffix(out.logicalName, filepath.Ext(out.logicalName))
|
||||||
|
|
||||||
|
if out.dir[len(out.dir)-1] != '/' {
|
||||||
|
out.dir += string(filepath.Separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(out.baseFileName, ".")
|
||||||
|
out.Language = NewLanguage(parts[len(parts)-1])
|
||||||
|
out.translationBaseName = strings.Join(parts[:len(parts)-1], ".")
|
||||||
|
out.contentBaseName = out.translationBaseName
|
||||||
|
|
||||||
|
switch out.translationBaseName {
|
||||||
|
default:
|
||||||
|
out.contentBaseName = out.translationBaseName
|
||||||
|
case "_index", "index":
|
||||||
|
out.contentBaseName = filepath.Base(out.dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := md5.New()
|
||||||
|
_, _ = hash.Write([]byte(out.path))
|
||||||
|
out.uniqueId = string(hash.Sum(nil))
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseFileName returns file name without extention:
|
||||||
|
//
|
||||||
|
// /news/a.en.md => a.en
|
||||||
|
// /news/b/index.en.md => index.en
|
||||||
|
// /news/_index.en.md => _index.en
|
||||||
|
func (p Path) BaseFileName() string {
|
||||||
|
return p.baseFileName
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentBaseName returns file or folder name based of index location:
|
||||||
|
//
|
||||||
|
// /news/a.en.md => a
|
||||||
|
// /news/b/index.en.md => b
|
||||||
|
// /news/_index.en.md => news
|
||||||
|
func (p Path) ContentBaseName() string {
|
||||||
|
return p.contentBaseName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dir returns directory path:
|
||||||
|
//
|
||||||
|
// /news/a.en.md => news/
|
||||||
|
// /news/b/index.en.md => news/b/
|
||||||
|
// /news/_index.en.md => news/
|
||||||
|
func (p Path) Dir() string {
|
||||||
|
return p.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ext returns file extention:
|
||||||
|
//
|
||||||
|
// /news/b/index.en.md => md
|
||||||
|
func (p Path) Ext() string {
|
||||||
|
return p.ext
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Path) Filename() string {
|
||||||
|
return p.filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogicalName returns fille file name in directory:
|
||||||
|
//
|
||||||
|
// /news/a.en.md => a.en.md
|
||||||
|
// /news/b/index.en.md => index.en.md
|
||||||
|
// /news/_index.en.md => _index.en.md
|
||||||
|
func (p Path) LogicalName() string {
|
||||||
|
return p.logicalName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Path) Path() string {
|
||||||
|
return p.path
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranslationBaseName returns file name without language code and extention:
|
||||||
|
//
|
||||||
|
// /news/a.en.md => a
|
||||||
|
// /news/b/index.en.md => index
|
||||||
|
// /news/_index.en.md => _index
|
||||||
|
func (p Path) TranslationBaseName() string {
|
||||||
|
return p.translationBaseName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Path) UniqueID() string {
|
||||||
|
return p.uniqueId
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Path) GoString() string {
|
||||||
|
return "domain.Path(" + p.path + ")"
|
||||||
|
}
|
|
@ -8,159 +8,159 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
testRegularFile string = filepath.Join("news", "a.en.md")
|
testRegularPath string = filepath.Join("news", "a.en.md")
|
||||||
testLeafFile string = filepath.Join("news", "b", "index.en.md")
|
testLeafPath string = filepath.Join("news", "b", "index.en.md")
|
||||||
testBranchFile string = filepath.Join("news", "_index.en.md")
|
testBranchPath string = filepath.Join("news", "_index.en.md")
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFile_BaseFileName(t *testing.T) {
|
func TestPath_BaseFileName(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for name, tc := range map[string]struct {
|
for name, tc := range map[string]struct {
|
||||||
input, expect string
|
input, expect string
|
||||||
}{
|
}{
|
||||||
"regular": {testRegularFile, "a.en"},
|
"regular": {testRegularPath, "a.en"},
|
||||||
"leaf": {testLeafFile, "index.en"},
|
"leaf": {testLeafPath, "index.en"},
|
||||||
"branch": {testBranchFile, "_index.en"},
|
"branch": {testBranchPath, "_index.en"},
|
||||||
} {
|
} {
|
||||||
name, tc := name, tc
|
name, tc := name, tc
|
||||||
|
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
if actual := domain.NewFile(tc.input).BaseFileName(); actual != tc.expect {
|
if actual := domain.NewPath(tc.input).BaseFileName(); actual != tc.expect {
|
||||||
t.Errorf("BaseFileName() = '%s', want '%s'", actual, tc.expect)
|
t.Errorf("BaseFileName() = '%s', want '%s'", actual, tc.expect)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFile_ContentBaseName(t *testing.T) {
|
func TestPath_ContentBaseName(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for name, tc := range map[string]struct {
|
for name, tc := range map[string]struct {
|
||||||
input, expect string
|
input, expect string
|
||||||
}{
|
}{
|
||||||
"regular": {testRegularFile, "a"},
|
"regular": {testRegularPath, "a"},
|
||||||
"leaf": {testLeafFile, "b"},
|
"leaf": {testLeafPath, "b"},
|
||||||
"branch": {testBranchFile, "news"},
|
"branch": {testBranchPath, "news"},
|
||||||
} {
|
} {
|
||||||
name, tc := name, tc
|
name, tc := name, tc
|
||||||
|
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
if actual := domain.NewFile(tc.input).ContentBaseName(); actual != tc.expect {
|
if actual := domain.NewPath(tc.input).ContentBaseName(); actual != tc.expect {
|
||||||
t.Errorf("ContentBaseName() = '%s', want '%s'", actual, tc.expect)
|
t.Errorf("ContentBaseName() = '%s', want '%s'", actual, tc.expect)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFile_Dir(t *testing.T) {
|
func TestPath_Dir(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for name, tc := range map[string]struct {
|
for name, tc := range map[string]struct {
|
||||||
input, expect string
|
input, expect string
|
||||||
}{
|
}{
|
||||||
"regular": {testRegularFile, "news/"},
|
"regular": {testRegularPath, "news/"},
|
||||||
"leaf": {testLeafFile, "news/b/"},
|
"leaf": {testLeafPath, "news/b/"},
|
||||||
"branch": {testBranchFile, "news/"},
|
"branch": {testBranchPath, "news/"},
|
||||||
} {
|
} {
|
||||||
name, tc := name, tc
|
name, tc := name, tc
|
||||||
|
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
if actual := domain.NewFile(tc.input).Dir(); actual != tc.expect {
|
if actual := domain.NewPath(tc.input).Dir(); actual != tc.expect {
|
||||||
t.Errorf("Dir() = '%s', want '%s'", actual, tc.expect)
|
t.Errorf("Dir() = '%s', want '%s'", actual, tc.expect)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFile_Ext(t *testing.T) {
|
func TestPath_Ext(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const expect string = "md"
|
const expect string = "md"
|
||||||
|
|
||||||
for name, input := range map[string]string{
|
for name, input := range map[string]string{
|
||||||
"regular": testRegularFile,
|
"regular": testRegularPath,
|
||||||
"leaf": testLeafFile,
|
"leaf": testLeafPath,
|
||||||
"branch": testBranchFile,
|
"branch": testBranchPath,
|
||||||
} {
|
} {
|
||||||
name, input := name, input
|
name, input := name, input
|
||||||
|
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
if actual := domain.NewFile(input).Ext(); actual != expect {
|
if actual := domain.NewPath(input).Ext(); actual != expect {
|
||||||
t.Errorf("Ext() = '%s', want '%s'", actual, expect)
|
t.Errorf("Ext() = '%s', want '%s'", actual, expect)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFile_Language(t *testing.T) {
|
func TestPath_Language(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var expect domain.Language = domain.NewLanguage("en")
|
var expect domain.Language = domain.NewLanguage("en")
|
||||||
|
|
||||||
for name, input := range map[string]string{
|
for name, input := range map[string]string{
|
||||||
"regular": testRegularFile,
|
"regular": testRegularPath,
|
||||||
"leaf": testLeafFile,
|
"leaf": testLeafPath,
|
||||||
"branch": testBranchFile,
|
"branch": testBranchPath,
|
||||||
} {
|
} {
|
||||||
name, input := name, input
|
name, input := name, input
|
||||||
|
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
if actual := domain.NewFile(input).Language; actual != expect {
|
if actual := domain.NewPath(input).Language; actual != expect {
|
||||||
t.Errorf("Language() = '%s', want '%s'", actual, expect)
|
t.Errorf("Language() = '%s', want '%s'", actual, expect)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFile_LogicalName(t *testing.T) {
|
func TestPath_LogicalName(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for name, tc := range map[string]struct {
|
for name, tc := range map[string]struct {
|
||||||
input, expect string
|
input, expect string
|
||||||
}{
|
}{
|
||||||
"regular": {testRegularFile, "a.en.md"},
|
"regular": {testRegularPath, "a.en.md"},
|
||||||
"leaf": {testLeafFile, "index.en.md"},
|
"leaf": {testLeafPath, "index.en.md"},
|
||||||
"branch": {testBranchFile, "_index.en.md"},
|
"branch": {testBranchPath, "_index.en.md"},
|
||||||
} {
|
} {
|
||||||
name, tc := name, tc
|
name, tc := name, tc
|
||||||
|
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
if actual := domain.NewFile(tc.input).LogicalName(); actual != tc.expect {
|
if actual := domain.NewPath(tc.input).LogicalName(); actual != tc.expect {
|
||||||
t.Errorf("LogicalName() = '%s', want '%s'", actual, tc.expect)
|
t.Errorf("LogicalName() = '%s', want '%s'", actual, tc.expect)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFile_TranslationBaseName(t *testing.T) {
|
func TestPath_TranslationBaseName(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for name, tc := range map[string]struct {
|
for name, tc := range map[string]struct {
|
||||||
input, expect string
|
input, expect string
|
||||||
}{
|
}{
|
||||||
"regular": {testRegularFile, "a"},
|
"regular": {testRegularPath, "a"},
|
||||||
"leaf": {testLeafFile, "index"},
|
"leaf": {testLeafPath, "index"},
|
||||||
"branch": {testBranchFile, "_index"},
|
"branch": {testBranchPath, "_index"},
|
||||||
} {
|
} {
|
||||||
name, tc := name, tc
|
name, tc := name, tc
|
||||||
|
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
if actual := domain.NewFile(tc.input).TranslationBaseName(); actual != tc.expect {
|
if actual := domain.NewPath(tc.input).TranslationBaseName(); actual != tc.expect {
|
||||||
t.Errorf("TranslationBaseName() = '%s', want '%s'", actual, tc.expect)
|
t.Errorf("TranslationBaseName() = '%s', want '%s'", actual, tc.expect)
|
||||||
}
|
}
|
||||||
})
|
})
|
|
@ -0,0 +1,15 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
type Redirects []Redirect
|
||||||
|
|
||||||
|
func (r Redirects) Match(p string) (*Redirect, bool) {
|
||||||
|
for i := range r {
|
||||||
|
if !r[i].IsMatch(p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return &r[i], true
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
|
@ -15,7 +15,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Resource struct {
|
type Resource struct {
|
||||||
File File
|
File Path
|
||||||
|
|
||||||
modTime time.Time
|
modTime time.Time
|
||||||
params map[string]any // TODO(toby3d): set from Page configuration
|
params map[string]any // TODO(toby3d): set from Page configuration
|
||||||
|
@ -30,7 +30,7 @@ type Resource struct {
|
||||||
func NewResource(modTime time.Time, r io.Reader, key string) *Resource {
|
func NewResource(modTime time.Time, r io.Reader, key string) *Resource {
|
||||||
mediaType, _, _ := mime.ParseMediaType(mime.TypeByExtension(path.Ext(key)))
|
mediaType, _, _ := mime.ParseMediaType(mime.TypeByExtension(path.Ext(key)))
|
||||||
out := &Resource{
|
out := &Resource{
|
||||||
File: NewFile(key),
|
File: NewPath(key),
|
||||||
modTime: modTime,
|
modTime: modTime,
|
||||||
key: key,
|
key: key,
|
||||||
name: key, // TODO(toby3d): set from Page configuration
|
name: key, // TODO(toby3d): set from Page configuration
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
package domain
|
package domain
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Headers []Header
|
Headers Headers
|
||||||
Redirects []Redirect
|
Redirects Redirects
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer() *Server {
|
func NewServer() *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
Headers: make([]Header, 0),
|
Headers: make(Headers, 0),
|
||||||
Redirects: make([]Redirect, 0),
|
Redirects: make(Redirects, 0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ type Site struct {
|
||||||
BaseURL *url.URL
|
BaseURL *url.URL
|
||||||
Params map[string]any
|
Params map[string]any
|
||||||
TimeZone *time.Location
|
TimeZone *time.Location
|
||||||
File File
|
File Path
|
||||||
Title string
|
Title string
|
||||||
Resources Resources
|
Resources Resources
|
||||||
}
|
}
|
||||||
|
@ -44,7 +44,7 @@ func TestSite(tb testing.TB) *Site {
|
||||||
Languages: []Language{en, ru},
|
Languages: []Language{en, ru},
|
||||||
BaseURL: &url.URL{Scheme: "http", Host: "127.0.0.1:3000", Path: "/"},
|
BaseURL: &url.URL{Scheme: "http", Host: "127.0.0.1:3000", Path: "/"},
|
||||||
TimeZone: time.UTC,
|
TimeZone: time.UTC,
|
||||||
File: NewFile(filepath.Join("content", "index.en.md")),
|
File: NewPath(filepath.Join("content", "index.en.md")),
|
||||||
Title: "Testing",
|
Title: "Testing",
|
||||||
Resources: make([]*Resource, 0),
|
Resources: make([]*Resource, 0),
|
||||||
Params: map[string]any{
|
Params: map[string]any{
|
||||||
|
|
|
@ -0,0 +1,67 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"source.toby3d.me/toby3d/home/internal/domain"
|
||||||
|
"source.toby3d.me/toby3d/home/internal/server"
|
||||||
|
"source.toby3d.me/toby3d/home/internal/site"
|
||||||
|
"source.toby3d.me/toby3d/home/internal/urlutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HeaderConfig struct {
|
||||||
|
Skipper Skipper
|
||||||
|
Siter site.UseCase
|
||||||
|
Serverer server.UseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
func Header(config HeaderConfig) Interceptor {
|
||||||
|
if config.Skipper == nil {
|
||||||
|
config.Skipper = DefaultSkipper
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Siter == nil {
|
||||||
|
panic("middleware: header: Siter is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Serverer == nil {
|
||||||
|
panic("middleware: header: Serverer is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||||
|
if config.Skipper(r) {
|
||||||
|
next(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lang, path := domain.LanguageUnd, r.URL.Path
|
||||||
|
if head, tail := urlutil.ShiftPath(r.URL.Path); head != "" {
|
||||||
|
if lang = domain.NewLanguage(head); lang != domain.LanguageUnd {
|
||||||
|
path = tail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
site, err := config.Siter.Do(r.Context(), lang)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := config.Serverer.Do(r.Context(), *site)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, header := range server.Headers.Match(path) {
|
||||||
|
for k, v := range header.Values {
|
||||||
|
w.Header().Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,25 +2,38 @@ package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
|
"source.toby3d.me/toby3d/home/internal/domain"
|
||||||
|
"source.toby3d.me/toby3d/home/internal/server"
|
||||||
|
"source.toby3d.me/toby3d/home/internal/site"
|
||||||
|
"source.toby3d.me/toby3d/home/internal/urlutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
RedirectConfig struct {
|
RedirectConfig struct {
|
||||||
Skipper Skipper
|
Skipper Skipper
|
||||||
Code int
|
Siter site.UseCase
|
||||||
|
Serverer server.UseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectLogic func(u *url.URL) (url string, ok bool)
|
redirectResponse struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
error error
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func Redirect(config RedirectConfig, redirect redirectLogic) Interceptor {
|
func Redirect(config RedirectConfig) Interceptor {
|
||||||
if config.Skipper == nil {
|
if config.Skipper == nil {
|
||||||
config.Skipper = DefaultSkipper
|
config.Skipper = DefaultSkipper
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Code == 0 {
|
if config.Siter == nil {
|
||||||
config.Code = http.StatusMovedPermanently
|
panic("middleware: redirect: Siter is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Serverer == nil {
|
||||||
|
panic("middleware: redirect: Serverer is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
return func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||||
|
@ -30,20 +43,69 @@ func Redirect(config RedirectConfig, redirect redirectLogic) Interceptor {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
u := &url.URL{
|
lang, path := domain.LanguageUnd, r.URL.Path
|
||||||
Scheme: "http",
|
if head, tail := urlutil.ShiftPath(r.URL.Path); head != "" {
|
||||||
Host: r.Host,
|
if lang = domain.NewLanguage(head); lang != domain.LanguageUnd {
|
||||||
Path: r.RequestURI,
|
path = tail
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.TLS != nil {
|
site, err := config.Siter.Do(r.Context(), lang)
|
||||||
u.Scheme += "s"
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if target, ok := redirect(u); ok {
|
server, err := config.Serverer.Do(r.Context(), *site)
|
||||||
http.RedirectHandler(target, config.Code).ServeHTTP(w, r)
|
if err != nil {
|
||||||
} else {
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect, ok := server.Redirects.Match(path)
|
||||||
|
if !ok {
|
||||||
next(w, r)
|
next(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE(toby3d): always redirect no matter what exists on
|
||||||
|
// requested URL.
|
||||||
|
if redirect.Force {
|
||||||
|
http.Redirect(w, r, redirect.To, redirect.Status)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := &redirectResponse{
|
||||||
|
error: nil,
|
||||||
|
statusCode: http.StatusOK,
|
||||||
|
ResponseWriter: w,
|
||||||
|
}
|
||||||
|
|
||||||
|
next(tx, r)
|
||||||
|
|
||||||
|
// NOTE(toby3d): redirect only if something bad on requested
|
||||||
|
// URL.
|
||||||
|
if tx.error == nil && http.StatusOK < tx.statusCode && tx.statusCode < http.StatusBadRequest {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, redirect.To, redirect.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *redirectResponse) WriteHeader(status int) {
|
||||||
|
r.statusCode = status
|
||||||
|
|
||||||
|
r.ResponseWriter.WriteHeader(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redirectResponse) Write(src []byte) (int, error) {
|
||||||
|
var length int
|
||||||
|
length, r.error = r.ResponseWriter.Write(src)
|
||||||
|
|
||||||
|
return length, r.error
|
||||||
|
}
|
||||||
|
|
|
@ -57,7 +57,7 @@ func (repo *fileSystemPageRepository) Get(ctx context.Context, lang domain.Langu
|
||||||
}
|
}
|
||||||
|
|
||||||
return &domain.Page{
|
return &domain.Page{
|
||||||
File: domain.NewFile(target),
|
File: domain.NewPath(target),
|
||||||
Language: lang,
|
Language: lang,
|
||||||
Title: data.Title,
|
Title: data.Title,
|
||||||
Content: data.Content,
|
Content: data.Content,
|
||||||
|
|
|
@ -71,7 +71,7 @@ func (repo *fileSystemSiteRepository) Get(ctx context.Context, lang domain.Langu
|
||||||
}
|
}
|
||||||
|
|
||||||
return &domain.Site{
|
return &domain.Site{
|
||||||
File: domain.NewFile(target),
|
File: domain.NewPath(target),
|
||||||
DefaultLanguage: data.DefaultLanguage.Language,
|
DefaultLanguage: data.DefaultLanguage.Language,
|
||||||
Language: lang,
|
Language: lang,
|
||||||
Title: data.Title,
|
Title: data.Title,
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"source.toby3d.me/toby3d/home/internal/static"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
static static.UseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(static static.UseCase) *Handler {
|
||||||
|
return &Handler{static: static}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s, err := h.static.Do(r.Context(), strings.TrimPrefix(path.Clean(r.URL.Path), "/"))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeContent(w, r, s.Name(), s.ModTime(), s)
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
package http_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"source.toby3d.me/toby3d/home/internal/domain"
|
||||||
|
delivery "source.toby3d.me/toby3d/home/internal/static/delivery/http"
|
||||||
|
repository "source.toby3d.me/toby3d/home/internal/static/repository/stub"
|
||||||
|
"source.toby3d.me/toby3d/home/internal/static/usecase"
|
||||||
|
"source.toby3d.me/toby3d/home/internal/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandler_ServeHTTP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/robots.txt", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
testStatic := domain.NewStatic(strings.NewReader("User-agent: *\nAllow: /"), time.Now().UTC(), "robots.txt")
|
||||||
|
delivery.NewHandler(usecase.NewStaticUseCase(repository.NewStubStaticRepository(nil, testStatic, false))).
|
||||||
|
ServeHTTP(w, req)
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
if expect := http.StatusOK; resp.StatusCode != expect {
|
||||||
|
t.Errorf("%s %s = %d, want %d", req.Method, req.RequestURI, resp.StatusCode, expect)
|
||||||
|
}
|
||||||
|
|
||||||
|
testutil.GoldenEqual(t, resp.Body)
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
|
@ -0,0 +1,7 @@
|
||||||
|
// The static package is a module for working with static files.
|
||||||
|
//
|
||||||
|
// These files are placed in a separate directory $HOME_STATIC_DIR and are
|
||||||
|
// available for embedding and reading as-is without access restrictions. The
|
||||||
|
// implication is that these files are often necessary for machines: logos,
|
||||||
|
// robots.txt, styles, public keys, sitemaps and so on.
|
||||||
|
package static
|
|
@ -6,19 +6,13 @@ import (
|
||||||
"source.toby3d.me/toby3d/home/internal/domain"
|
"source.toby3d.me/toby3d/home/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type Repository interface {
|
||||||
Repository interface {
|
// Create copy static into store to path.
|
||||||
// Get returns Static on path if exists
|
Create(ctx context.Context, static domain.Static, path string) (bool, error)
|
||||||
Get(ctx context.Context, path string) (*domain.Static, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
dummyRepository struct{}
|
// Get returns static on path if exists.
|
||||||
)
|
Get(ctx context.Context, path string) (*domain.Static, error)
|
||||||
|
|
||||||
func NewDummyRepository() dummyRepository {
|
// Delete remove static from store if exists.
|
||||||
return dummyRepository{}
|
Delete(ctx context.Context, path string) (bool, error)
|
||||||
}
|
|
||||||
|
|
||||||
func (dummyRepository) Get(ctx context.Context, path string) (*domain.Static, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
package dummy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"source.toby3d.me/toby3d/home/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dummyStaticRepository struct{}
|
||||||
|
|
||||||
|
// NewDummyRepository creates a new dummy static repository which will be used
|
||||||
|
// as argument to functions that you don’t care about.
|
||||||
|
func NewDummyStaticRepository() dummyStaticRepository {
|
||||||
|
return dummyStaticRepository{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dummyStaticRepository) Create(_ context.Context, _ domain.Static, _ string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dummyStaticRepository) Get(_ context.Context, _ string) (*domain.Static, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dummyStaticRepository) Delete(_ context.Context, _ string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
|
@ -3,12 +3,15 @@ package fs
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
_ "image/jpeg"
|
_ "image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
_ "golang.org/x/image/bmp"
|
_ "golang.org/x/image/bmp"
|
||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
|
@ -18,22 +21,30 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type fileServerStaticRepository struct {
|
type fileServerStaticRepository struct {
|
||||||
root fs.FS
|
store fs.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFileServerStaticRepository(root fs.FS) static.Repository {
|
func (repo *fileServerStaticRepository) Create(_ context.Context, s domain.Static, p string) (bool, error) {
|
||||||
return &fileServerStaticRepository{
|
f, err := os.OpenFile(filepath.Clean(p), os.O_WRONLY, os.ModePerm)
|
||||||
root: root,
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("cannot open static for writing: %w", err)
|
||||||
}
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err = io.Copy(f, &s); err != nil {
|
||||||
|
return false, fmt.Errorf("cannot copy static: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (repo *fileServerStaticRepository) Get(ctx context.Context, p string) (*domain.Static, error) {
|
func (repo *fileServerStaticRepository) Get(_ context.Context, p string) (*domain.Static, error) {
|
||||||
info, err := fs.Stat(repo.root, p)
|
info, err := fs.Stat(repo.store, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot stat static on path '%s': %w", p, err)
|
return nil, fmt.Errorf("cannot stat static on path '%s': %w", p, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := repo.root.Open(p)
|
f, err := repo.store.Open(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot open static on path '%s': %w", p, err)
|
return nil, fmt.Errorf("cannot open static on path '%s': %w", p, err)
|
||||||
}
|
}
|
||||||
|
@ -46,3 +57,30 @@ func (repo *fileServerStaticRepository) Get(ctx context.Context, p string) (*dom
|
||||||
|
|
||||||
return domain.NewStatic(bytes.NewReader(content), info.ModTime(), info.Name()), nil
|
return domain.NewStatic(bytes.NewReader(content), info.ModTime(), info.Name()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (repo *fileServerStaticRepository) Delete(_ context.Context, p string) (bool, error) {
|
||||||
|
p = filepath.Clean(p)
|
||||||
|
|
||||||
|
_, err := fs.Stat(repo.store, p)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, fmt.Errorf("cannot open static for writing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = os.RemoveAll(p); err != nil {
|
||||||
|
return false, fmt.Errorf("cannot remove static: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileServerStaticRepository creates a new FS repository for static files
|
||||||
|
// which must be uploaded and used as is.
|
||||||
|
func NewFileServerStaticRepository(store fs.FS) static.Repository {
|
||||||
|
return &fileServerStaticRepository{
|
||||||
|
store: store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
package stub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"source.toby3d.me/toby3d/home/internal/domain"
|
||||||
|
"source.toby3d.me/toby3d/home/internal/static"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubStaticRepository struct {
|
||||||
|
static *domain.Static
|
||||||
|
error error
|
||||||
|
status bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *stubStaticRepository) Create(_ context.Context, _ domain.Static, _ string) (bool, error) {
|
||||||
|
return repo.status, repo.error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *stubStaticRepository) Delete(_ context.Context, _ string) (bool, error) {
|
||||||
|
return repo.status, repo.error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *stubStaticRepository) Get(_ context.Context, _ string) (*domain.Static, error) {
|
||||||
|
return repo.static, repo.error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStubStaticRepository(err error, s *domain.Static, ok bool) static.Repository {
|
||||||
|
return &stubStaticRepository{
|
||||||
|
static: s,
|
||||||
|
error: err,
|
||||||
|
status: ok,
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,5 +7,6 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type UseCase interface {
|
type UseCase interface {
|
||||||
|
// Do search static on path and returns Static domain if exist.
|
||||||
Do(ctx context.Context, path string) (*domain.Static, error)
|
Do(ctx context.Context, path string) (*domain.Static, error)
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,19 +11,19 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type staticUseCase struct {
|
type staticUseCase struct {
|
||||||
statics static.Repository
|
store static.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStaticUseCase(statics static.Repository) static.UseCase {
|
func NewStaticUseCase(store static.Repository) static.UseCase {
|
||||||
return &staticUseCase{
|
return &staticUseCase{
|
||||||
statics: statics,
|
store: store,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ucase *staticUseCase) Do(ctx context.Context, p string) (*domain.Static, error) {
|
func (ucase *staticUseCase) Do(ctx context.Context, p string) (*domain.Static, error) {
|
||||||
p = strings.TrimPrefix(path.Clean(p), "/")
|
p = strings.TrimPrefix(path.Clean(p), "/")
|
||||||
|
|
||||||
s, err := ucase.statics.Get(ctx, p)
|
s, err := ucase.store.Get(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot get static file: %w", err)
|
return nil, fmt.Errorf("cannot get static file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var update *bool = flag.Bool("update", false, "save current tests results as golden files")
|
||||||
|
|
||||||
|
// GoldenEqual compares the bytes of the provided r with the contents of the
|
||||||
|
// golden file for a complete data match.
|
||||||
|
//
|
||||||
|
// When running tests with the -update flag, the contents of golden-files will
|
||||||
|
// be overwritten with the provided contents of r, creating the testdata/
|
||||||
|
// directory if it does not exist.
|
||||||
|
//
|
||||||
|
//nolint:gocognit,gocyclo
|
||||||
|
func GoldenEqual(tb testing.TB, r io.Reader) {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatal("cannot get current working directory path:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatal("cannot read provided data:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join(wd, "testdata")
|
||||||
|
file := filepath.Join(dir, tb.Name()[4:]+".golden")
|
||||||
|
|
||||||
|
if *update {
|
||||||
|
_, err = os.Stat(dir)
|
||||||
|
if err != nil && !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
tb.Fatal("cannot create testdata folder for golden files:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
if err = os.Mkdir(dir, os.ModePerm); err != nil {
|
||||||
|
tb.Fatal("cannot create testdata folder for golden files:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = os.WriteFile(file, actual, os.ModePerm); err != nil {
|
||||||
|
tb.Fatal("cannot write data into golden file:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tb.Skip("skipped due force updating golden file")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expected, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatal("cannot read golden file data:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if diff := cmp.Diff(string(actual), string(expected)); diff != "" {
|
||||||
|
tb.Error(diff)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue