Compare commits

...

12 Commits

29 changed files with 689 additions and 278 deletions

23
.editorconfig Normal file
View File

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

View File

@ -57,7 +57,7 @@ func NewApp(logger *log.Logger, config *domain.Config) (*App, error) {
themer := themeucase.NewThemeUseCase(partialsDir, themes)
pages := pagefsrepo.NewFileSystemPageRepository(contentDir)
pager := pageucase.NewPageUseCase(pages, resources)
server := servercase.NewServerUseCase()
serverer := servercase.NewServerUseCase()
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
// 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
}
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() {
head, tail := urlutil.ShiftPath(r.URL.Path)
if head == "" {
supported := make([]language.Tag, len(s.Languages))
for i := range s.Languages {
@ -125,8 +90,8 @@ func NewApp(logger *log.Logger, config *domain.Config) (*App, error) {
supported...)
}
requested, _, err := language.ParseAcceptLanguage(
r.Header.Get(common.HeaderAcceptLanguage))
requested, _, err := language.ParseAcceptLanguage(r.Header.Get(
common.HeaderAcceptLanguage))
if err != nil || len(requested) == 0 {
requested = append(requested, language.English)
}
@ -139,14 +104,9 @@ func NewApp(logger *log.Logger, config *domain.Config) (*App, error) {
return
}
lang = domain.NewLanguage(head)
r.URL.Path = tail
}
if lang == domain.LanguageUnd && redirect != nil {
http.Redirect(w, r, redirect.To, redirect.Status)
return
if lang = domain.NewLanguage(head); lang != domain.LanguageUnd {
r.URL.Path = tail
}
}
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
}
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)
if err != nil {
if !errors.Is(err, page.ErrNotExist) {
@ -195,12 +123,6 @@ func NewApp(logger *log.Logger, config *domain.Config) (*App, error) {
return
}
if redirect != nil {
http.Redirect(w, r, redirect.To, redirect.Status)
return
}
res, err := resourcer.Do(r.Context(), r.URL.Path)
if err != nil {
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)
}
})
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{
Addr: config.AddrPort().String(),

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ type Language struct {
code string
lang string
name string
dir string
dir Direction
}
var LanguageUnd Language = Language{} // "und"
@ -26,13 +26,13 @@ func NewLanguage(raw string) Language {
out := Language{
code: tag.String(),
dir: "ltr",
dir: DirectionLeftToRight,
name: strings.ToLower(display.Self.Name(tag)),
}
switch tag {
case language.Arabic, language.Persian, language.Hebrew, language.Urdu:
out.dir = "rtl"
out.dir = DirectionRightToLeft
}
base, _ := tag.Base()
@ -49,7 +49,7 @@ func (l Language) Code() string {
return l.code
}
func (l Language) Dir() string {
func (l Language) Dir() Direction {
return l.dir
}
@ -58,7 +58,7 @@ func (l Language) Name() string {
}
func (l Language) String() string {
if l.code == "" {
if l.code != "" {
return l.code
}

View File

@ -60,13 +60,13 @@ func TestLanguage_Dir(t *testing.T) {
for name, tc := range map[string]struct {
input string
expect string
expect domain.Direction
}{
"2letter": {"en", "ltr"},
"rtl": {"ur", "rtl"},
"3letter": {"eng", "ltr"},
"region": {"en-US", "ltr"},
common.Und: {"", ""},
"2letter": {"en", domain.DirectionLeftToRight},
"rtl": {"ur", domain.DirectionRightToLeft},
"3letter": {"eng", domain.DirectionLeftToRight},
"region": {"en-US", domain.DirectionLeftToRight},
common.Und: {"", domain.DirectionUnd},
} {
name, tc := name, tc

View File

@ -3,7 +3,7 @@ package domain
type Page struct {
Language Language
Params map[string]any
File File
File Path
Description string
Title string
Content []byte

126
internal/domain/path.go Normal file
View File

@ -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 + ")"
}

View File

@ -8,159 +8,159 @@ import (
)
var (
testRegularFile string = filepath.Join("news", "a.en.md")
testLeafFile string = filepath.Join("news", "b", "index.en.md")
testBranchFile string = filepath.Join("news", "_index.en.md")
testRegularPath string = filepath.Join("news", "a.en.md")
testLeafPath string = filepath.Join("news", "b", "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()
for name, tc := range map[string]struct {
input, expect string
}{
"regular": {testRegularFile, "a.en"},
"leaf": {testLeafFile, "index.en"},
"branch": {testBranchFile, "_index.en"},
"regular": {testRegularPath, "a.en"},
"leaf": {testLeafPath, "index.en"},
"branch": {testBranchPath, "_index.en"},
} {
name, tc := name, tc
t.Run(name, func(t *testing.T) {
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)
}
})
}
}
func TestFile_ContentBaseName(t *testing.T) {
func TestPath_ContentBaseName(t *testing.T) {
t.Parallel()
for name, tc := range map[string]struct {
input, expect string
}{
"regular": {testRegularFile, "a"},
"leaf": {testLeafFile, "b"},
"branch": {testBranchFile, "news"},
"regular": {testRegularPath, "a"},
"leaf": {testLeafPath, "b"},
"branch": {testBranchPath, "news"},
} {
name, tc := name, tc
t.Run(name, func(t *testing.T) {
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)
}
})
}
}
func TestFile_Dir(t *testing.T) {
func TestPath_Dir(t *testing.T) {
t.Parallel()
for name, tc := range map[string]struct {
input, expect string
}{
"regular": {testRegularFile, "news/"},
"leaf": {testLeafFile, "news/b/"},
"branch": {testBranchFile, "news/"},
"regular": {testRegularPath, "news/"},
"leaf": {testLeafPath, "news/b/"},
"branch": {testBranchPath, "news/"},
} {
name, tc := name, tc
t.Run(name, func(t *testing.T) {
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)
}
})
}
}
func TestFile_Ext(t *testing.T) {
func TestPath_Ext(t *testing.T) {
t.Parallel()
const expect string = "md"
for name, input := range map[string]string{
"regular": testRegularFile,
"leaf": testLeafFile,
"branch": testBranchFile,
"regular": testRegularPath,
"leaf": testLeafPath,
"branch": testBranchPath,
} {
name, input := name, input
t.Run(name, func(t *testing.T) {
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)
}
})
}
}
func TestFile_Language(t *testing.T) {
func TestPath_Language(t *testing.T) {
t.Parallel()
var expect domain.Language = domain.NewLanguage("en")
for name, input := range map[string]string{
"regular": testRegularFile,
"leaf": testLeafFile,
"branch": testBranchFile,
"regular": testRegularPath,
"leaf": testLeafPath,
"branch": testBranchPath,
} {
name, input := name, input
t.Run(name, func(t *testing.T) {
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)
}
})
}
}
func TestFile_LogicalName(t *testing.T) {
func TestPath_LogicalName(t *testing.T) {
t.Parallel()
for name, tc := range map[string]struct {
input, expect string
}{
"regular": {testRegularFile, "a.en.md"},
"leaf": {testLeafFile, "index.en.md"},
"branch": {testBranchFile, "_index.en.md"},
"regular": {testRegularPath, "a.en.md"},
"leaf": {testLeafPath, "index.en.md"},
"branch": {testBranchPath, "_index.en.md"},
} {
name, tc := name, tc
t.Run(name, func(t *testing.T) {
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)
}
})
}
}
func TestFile_TranslationBaseName(t *testing.T) {
func TestPath_TranslationBaseName(t *testing.T) {
t.Parallel()
for name, tc := range map[string]struct {
input, expect string
}{
"regular": {testRegularFile, "a"},
"leaf": {testLeafFile, "index"},
"branch": {testBranchFile, "_index"},
"regular": {testRegularPath, "a"},
"leaf": {testLeafPath, "index"},
"branch": {testBranchPath, "_index"},
} {
name, tc := name, tc
t.Run(name, func(t *testing.T) {
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)
}
})

View File

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

View File

@ -15,7 +15,7 @@ import (
)
type Resource struct {
File File
File Path
modTime time.Time
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 {
mediaType, _, _ := mime.ParseMediaType(mime.TypeByExtension(path.Ext(key)))
out := &Resource{
File: NewFile(key),
File: NewPath(key),
modTime: modTime,
key: key,
name: key, // TODO(toby3d): set from Page configuration

View File

@ -1,13 +1,13 @@
package domain
type Server struct {
Headers []Header
Redirects []Redirect
Headers Headers
Redirects Redirects
}
func NewServer() *Server {
return &Server{
Headers: make([]Header, 0),
Redirects: make([]Redirect, 0),
Headers: make(Headers, 0),
Redirects: make(Redirects, 0),
}
}

View File

@ -15,7 +15,7 @@ type Site struct {
BaseURL *url.URL
Params map[string]any
TimeZone *time.Location
File File
File Path
Title string
Resources Resources
}
@ -44,7 +44,7 @@ func TestSite(tb testing.TB) *Site {
Languages: []Language{en, ru},
BaseURL: &url.URL{Scheme: "http", Host: "127.0.0.1:3000", Path: "/"},
TimeZone: time.UTC,
File: NewFile(filepath.Join("content", "index.en.md")),
File: NewPath(filepath.Join("content", "index.en.md")),
Title: "Testing",
Resources: make([]*Resource, 0),
Params: map[string]any{

View File

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

View File

@ -2,25 +2,38 @@ package middleware
import (
"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 (
RedirectConfig struct {
Skipper Skipper
Code int
Skipper Skipper
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 {
config.Skipper = DefaultSkipper
}
if config.Code == 0 {
config.Code = http.StatusMovedPermanently
if config.Siter == nil {
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) {
@ -30,20 +43,69 @@ func Redirect(config RedirectConfig, redirect redirectLogic) Interceptor {
return
}
u := &url.URL{
Scheme: "http",
Host: r.Host,
Path: r.RequestURI,
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
}
}
if r.TLS != nil {
u.Scheme += "s"
site, err := config.Siter.Do(r.Context(), lang)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if target, ok := redirect(u); ok {
http.RedirectHandler(target, config.Code).ServeHTTP(w, r)
} else {
server, err := config.Serverer.Do(r.Context(), *site)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
redirect, ok := server.Redirects.Match(path)
if !ok {
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
}

View File

@ -57,7 +57,7 @@ func (repo *fileSystemPageRepository) Get(ctx context.Context, lang domain.Langu
}
return &domain.Page{
File: domain.NewFile(target),
File: domain.NewPath(target),
Language: lang,
Title: data.Title,
Content: data.Content,

View File

@ -71,7 +71,7 @@ func (repo *fileSystemSiteRepository) Get(ctx context.Context, lang domain.Langu
}
return &domain.Site{
File: domain.NewFile(target),
File: domain.NewPath(target),
DefaultLanguage: data.DefaultLanguage.Language,
Language: lang,
Title: data.Title,

View File

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

View File

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

View File

@ -0,0 +1,2 @@
User-agent: *
Allow: /

7
internal/static/doc.go Normal file
View File

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

View File

@ -6,19 +6,13 @@ import (
"source.toby3d.me/toby3d/home/internal/domain"
)
type (
Repository interface {
// Get returns Static on path if exists
Get(ctx context.Context, path string) (*domain.Static, error)
}
type Repository interface {
// Create copy static into store to path.
Create(ctx context.Context, static domain.Static, path string) (bool, error)
dummyRepository struct{}
)
// Get returns static on path if exists.
Get(ctx context.Context, path string) (*domain.Static, error)
func NewDummyRepository() dummyRepository {
return dummyRepository{}
}
func (dummyRepository) Get(ctx context.Context, path string) (*domain.Static, error) {
return nil, nil
// Delete remove static from store if exists.
Delete(ctx context.Context, path string) (bool, error)
}

View File

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

View File

@ -3,12 +3,15 @@ package fs
import (
"bytes"
"context"
"errors"
"fmt"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"io/fs"
"os"
"path/filepath"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/webp"
@ -18,22 +21,30 @@ import (
)
type fileServerStaticRepository struct {
root fs.FS
store fs.FS
}
func NewFileServerStaticRepository(root fs.FS) static.Repository {
return &fileServerStaticRepository{
root: root,
func (repo *fileServerStaticRepository) Create(_ context.Context, s domain.Static, p string) (bool, error) {
f, err := os.OpenFile(filepath.Clean(p), os.O_WRONLY, os.ModePerm)
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) {
info, err := fs.Stat(repo.root, p)
func (repo *fileServerStaticRepository) Get(_ context.Context, p string) (*domain.Static, error) {
info, err := fs.Stat(repo.store, p)
if err != nil {
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 {
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
}
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,
}
}

View File

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

View File

@ -7,5 +7,6 @@ import (
)
type UseCase interface {
// Do search static on path and returns Static domain if exist.
Do(ctx context.Context, path string) (*domain.Static, error)
}

View File

@ -11,19 +11,19 @@ import (
)
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{
statics: statics,
store: store,
}
}
func (ucase *staticUseCase) Do(ctx context.Context, p string) (*domain.Static, error) {
p = strings.TrimPrefix(path.Clean(p), "/")
s, err := ucase.statics.Get(ctx, p)
s, err := ucase.store.Get(ctx, p)
if err != nil {
return nil, fmt.Errorf("cannot get static file: %w", err)
}

View File

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