Initial commit

This commit is contained in:
2025-09-06 21:35:45 -04:00
commit b02525e28a
32 changed files with 1478 additions and 0 deletions

12
pkg/router/context.go Normal file
View File

@@ -0,0 +1,12 @@
package router
import (
"addrss/pkg/auth"
)
type Context struct {
Response Response
Claims auth.AccessClaims
Request Request
route Route
}

11
pkg/router/controller.go Normal file
View File

@@ -0,0 +1,11 @@
package router
type Controller interface {
AddRoutes()
}
func AddControllers(controllers ...Controller) {
for _, controller := range controllers {
controller.AddRoutes()
}
}

76
pkg/router/request.go Normal file
View File

@@ -0,0 +1,76 @@
package router
import (
"addrss/pkg/config"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"strconv"
"strings"
)
type Request struct {
*http.Request
params map[string]any
}
func (req Request) Param(key string) (any, error) {
value, ok := req.params[key]
if ok {
return value, nil
}
return nil, fmt.Errorf("param %s not found", key)
}
func (req Request) ParamInt64(key string) (int64, error) {
param, err := req.Param(key)
if err != nil {
return 0, err
}
i, err := strconv.Atoi(param.(string))
if err != nil {
return 0, err
}
return int64(i), nil
}
// Bind Binds the request body to the struct pointer v using the Content-Type header to select a binder. This function panics if v cannot be bound.
func (req Request) Bind(v any) error {
mime := req.Header.Get("Content-Type")
mime = strings.Split(mime, ";")[0]
switch mime {
case "application/json":
err := json.NewDecoder(req.Body).Decode(&v)
if err != nil {
return fmt.Errorf("error while decoding http request body as json: %w", err)
}
default:
return fmt.Errorf("no binder exists for type %s", mime)
}
return nil
}
func (req Request) FormFile(key string) (multipart.File, *multipart.FileHeader, error) {
maxMegs, err := config.GetInt64("uploads.maxMegabytes")
if err != nil {
maxMegs = 8000000
}
if err := req.ParseMultipartForm(maxMegs << 20); err != nil {
return nil, nil, err
}
file, header, err := req.Request.FormFile(key)
if err != nil {
return nil, nil, err
}
return file, header, nil
}

99
pkg/router/response.go Normal file
View File

@@ -0,0 +1,99 @@
package router
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"net/http"
"path/filepath"
)
type Response struct {
http.ResponseWriter
}
func (resp Response) View(name string, data any) {
fp := filepath.Join("internal/views", fmt.Sprintf("%s.html", name))
tmpl, _ := template.ParseFiles(fp)
buf := new(bytes.Buffer)
if err := tmpl.ExecuteTemplate(buf, "index", data); err != nil {
resp.statusCode(http.StatusInternalServerError)
return
}
resp.html(buf.Bytes(), http.StatusOK)
}
func (resp Response) OK(data any) {
resp.json(data, http.StatusOK)
}
func (resp Response) NoContent() {
resp.statusCode(http.StatusNoContent)
}
func (resp Response) BadRequest(err error) {
resp.error(err, http.StatusBadRequest)
}
func (resp Response) Unauthorized(err error) {
resp.error(err, http.StatusUnauthorized)
}
func (resp Response) Forbidden(err error) {
resp.error(err, http.StatusForbidden)
}
func (resp Response) NotFound(err error) {
resp.error(err, http.StatusNotFound)
}
func (resp Response) Conflict(err error) {
resp.error(err, http.StatusConflict)
}
func (resp Response) UnsupportedMediaType(err error) {
resp.error(err, http.StatusUnsupportedMediaType)
}
func (resp Response) InternalServerError(err error) {
resp.error(err, http.StatusInternalServerError)
}
func (resp Response) error(err error, status int) {
er := struct {
Message string `json:"message"`
}{
Message: err.Error(),
}
resp.json(er, status)
}
func (resp Response) html(content []byte, status int) {
resp.Header().Add("Content-Type", "text/html; charset=utf-8")
resp.WriteHeader(status)
if _, err := resp.Write(content); err != nil {
panic("Failed to write HTTP response: " + err.Error())
}
}
func (resp Response) json(data any, statusCode int) {
content, err := json.Marshal(data)
if err != nil {
panic("Failed to marshal JSON: " + err.Error())
}
resp.Header().Add("Content-Type", "application/json; charset=utf-8")
resp.WriteHeader(statusCode)
if _, err := resp.Write(content); err != nil {
panic("Failed to write HTTP response: " + err.Error())
}
}
func (resp Response) statusCode(code int) {
resp.WriteHeader(code)
}

33
pkg/router/route.go Normal file
View File

@@ -0,0 +1,33 @@
package router
type Route struct {
handler func(*Context)
method string
anonymous bool
scope string
params map[string]any
path string
bindModel any
}
func (r Route) Anonymous() Route {
for i, route := range routeMap[r.method] {
if route.path == r.path {
routeMap[r.method][i].scope = ""
routeMap[r.method][i].anonymous = true
}
}
return r
}
func (r Route) RequireScope(scope string) Route {
for i, route := range routeMap[r.method] {
if route.path == r.path {
routeMap[r.method][i].scope = scope
routeMap[r.method][i].anonymous = false
}
}
return r
}

192
pkg/router/router.go Normal file
View File

@@ -0,0 +1,192 @@
package router
import (
"addrss/pkg/auth"
"addrss/pkg/config"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
)
var routeMap = map[string][]Route{}
type router struct{}
func Serve(addr string) error {
if len(routeMap) == 0 {
return errors.New("no registered routes")
}
return http.ListenAndServe(addr, router{})
}
func (r router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
route, ok := getRoute(req)
if !ok {
// See if the request is for a file that exists in the content directory
file := filepath.Join("content", req.URL.Path)
if info, err := os.Stat(file); err == nil && !info.IsDir() {
http.ServeFile(w, req, file)
return
}
w.WriteHeader(http.StatusNotFound)
return
}
ctx := Context{
Request: Request{req, map[string]any{}},
Response: Response{w},
Claims: auth.AccessClaims{},
route: route,
}
defer func() {
if rec := recover(); rec != nil {
err := fmt.Errorf("recovered from panic: %v", rec)
ctx.Response.InternalServerError(err)
}
}()
originHeader := req.Header.Get("Origin")
if originHeader != "" {
o, err := config.Get("cors.origins")
if err != nil {
panic(err)
}
origins := o.([]any)
for _, origin := range origins {
if origin.(string) == originHeader {
w.Header().Add("Access-Control-Allow-Origin", origin.(string))
}
}
}
if req.Method == http.MethodOptions {
acrh := req.Header.Get("Access-Control-Request-Headers")
acrm := req.Header.Get("Access-Control-Request-Method")
if route.method != acrm {
w.WriteHeader(http.StatusForbidden)
return
}
// Congrats, you get whatever headers you want
w.Header().Add("Access-Control-Allow-Headers", acrh)
w.Header().Add("Access-Control-Allow-Methods", acrm)
w.WriteHeader(http.StatusNoContent)
return
}
if !route.anonymous {
authSegments := strings.Split(req.Header.Get("Authorization"), " ")
if len(authSegments) != 2 || authSegments[0] != "Bearer" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if err := auth.ValidateJwtToken(authSegments[1], &ctx.Claims); err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
}
if route.scope != "" {
if !strings.Contains(ctx.Claims.Scopes, route.scope) {
w.WriteHeader(http.StatusForbidden)
return
}
}
route.handler(&ctx)
}
func AddGet(pattern string, handler func(*Context)) Route {
return addRoute(http.MethodGet, pattern, handler)
}
func AddPost(pattern string, handler func(*Context)) Route {
return addRoute(http.MethodPost, pattern, handler)
}
func AddPut(pattern string, handler func(*Context)) Route {
return addRoute(http.MethodPut, pattern, handler)
}
func AddPatch(pattern string, handler func(*Context)) Route {
return addRoute(http.MethodPatch, pattern, handler)
}
func AddDelete(pattern string, handler func(*Context)) Route {
return addRoute(http.MethodDelete, pattern, handler)
}
func addRoute(method string, path string, handler func(*Context)) Route {
route := Route{
handler: handler,
method: method,
path: path,
}
if routeMap[method] == nil {
routeMap[method] = []Route{}
}
routeMap[method] = append(routeMap[method], route)
return route
}
func getRoute(req *http.Request) (Route, bool) {
method := req.Method
if method == http.MethodOptions {
method = req.Header.Get("Access-Control-Request-Method")
}
req.URL.Path = strings.TrimSuffix(req.URL.Path, "/")
for _, route := range routeMap[method] {
//if strings.Contains(route.path, ":") {
// rte, ok := regexMatchRoute(route, req.URL.Path)
// if ok {
// return rte, true
// }
//} else {
if route.path == req.URL.Path {
return route, true
}
//}
}
return Route{}, false
}
//func regexMatchRoute(route Route, path string) (Route, bool) {
// regex := regexp.MustCompile(`:[a-zA-Z]+`)
// matchPath := regex.ReplaceAllString(route.path, `([A-Za-z0-9\-_.]+)`)
// matchPath += "$"
// regex = regexp.MustCompile(matchPath)
//
// // matchPath := route.path
// if matches := regex.FindStringSubmatch(path); len(matches) > 0 {
// if len(matches) > 1 {
// route.params = map[string]any{}
//
// values := strings.Split(path, "/")
// for i, param := range strings.Split(route.path, "/") {
// if strings.Contains(param, ":") {
// route.params[param[1:]] = values[i]
// }
// }
// }
//
// return route, true
// }
//
// return route, false
//}