goshort/internal/server/api/handler.go
Gustavo Maronato 4fef573447
Some checks failed
Check / checks (push) Failing after 4m11s
added frontend oidc support
2024-03-09 05:42:36 -05:00

745 lines
17 KiB
Go

package apiserver
import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"git.maronato.dev/maronato/goshort/internal/errs"
"git.maronato.dev/maronato/goshort/internal/server"
authmiddleware "git.maronato.dev/maronato/goshort/internal/server/middleware/auth"
configservice "git.maronato.dev/maronato/goshort/internal/service/config"
shortservice "git.maronato.dev/maronato/goshort/internal/service/short"
shortlogservice "git.maronato.dev/maronato/goshort/internal/service/shortlog"
tokenservice "git.maronato.dev/maronato/goshort/internal/service/token"
userservice "git.maronato.dev/maronato/goshort/internal/service/user"
"git.maronato.dev/maronato/goshort/internal/storage/models"
"git.maronato.dev/maronato/goshort/internal/util/logging"
"git.maronato.dev/maronato/goshort/internal/util/tracing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
type APIHandler struct {
shorts *shortservice.ShortService
users *userservice.UserService
tokens *tokenservice.TokenService
shortLogs *shortlogservice.ShortLogService
config *configservice.ConfigService
}
func NewAPIHandler(
shorts *shortservice.ShortService,
users *userservice.UserService,
tokens *tokenservice.TokenService,
shortLogs *shortlogservice.ShortLogService,
config *configservice.ConfigService,
) *APIHandler {
return &APIHandler{
shorts: shorts,
users: users,
tokens: tokens,
shortLogs: shortLogs,
config: config,
}
}
func (h *APIHandler) Me(w http.ResponseWriter, r *http.Request) {
_, span := tracing.StartSpan(r.Context(), "api.Me")
defer span.End()
// Get user from context
user, ok := h.findUserOrRespond(w, r)
if !ok {
return
}
span.AddEvent("found user")
// Respond with the user
render.Status(r, http.StatusOK)
render.JSON(w, r, user)
}
func (h *APIHandler) DeleteMe(w http.ResponseWriter, r *http.Request) {
ctx, span := tracing.StartSpan(r.Context(), "api.DeleteMe")
defer span.End()
// Get user from context
user, ok := h.findUserOrRespond(w, r)
if !ok {
return
}
span.AddEvent("found user")
// Delete all user's sessions
err := authmiddleware.DeleteAllUserSessions(ctx, user)
if err != nil {
server.RenderServerError(w, r, err)
return
}
span.AddEvent("deleted all user sessions")
// Delete the user
err = h.users.DeleteUser(ctx, user)
if err != nil {
server.RenderServerError(w, r, err)
return
}
span.AddEvent("deleted user")
// Logout and return
authmiddleware.LogoutUser(ctx)
span.AddEvent("logged out user")
render.NoContent(w, r)
}
func (h *APIHandler) Login(w http.ResponseWriter, r *http.Request) {
ctx, span := tracing.StartSpan(r.Context(), "api.Login")
defer span.End()
l := logging.FromCtx(ctx)
type loginForm struct {
Username string `json:"username"`
Password string `json:"password"`
}
var form *loginForm
if err := render.DecodeJSON(r.Body, &form); err != nil {
server.RenderBadRequest(w, r, err)
return
}
// Get user from storage
user, err := h.users.AuthenticateUser(ctx, form.Username, form.Password)
if err != nil {
switch {
case errors.Is(err, errs.ErrUserDoesNotExist) || errors.Is(err, errs.ErrFailedAuthentication):
// If the username or password are wrong, return invalid username/password
l.Debug("failed to authenticate user", "err", err)
server.RenderUnauthorized(w, r)
case errors.Is(err, errs.ErrInvalidUser):
// If the request was invalid, return bad request
server.RenderBadRequest(w, r, err)
case errors.Is(err, errs.ErrCredentialsLoginDisabled):
// If credentials login is disabled, return forbidden
server.RenderForbidden(w, r)
default:
// Else, server error
server.RenderServerError(w, r, err)
}
return
}
span.AddEvent("authenticated user")
// Login user
authmiddleware.LoginUser(ctx, user, r)
span.AddEvent("logged in user")
// Render the response
render.Status(r, http.StatusOK)
render.JSON(w, r, user)
}
func (h *APIHandler) Logout(w http.ResponseWriter, r *http.Request) {
ctx, span := tracing.StartSpan(r.Context(), "api.Logout")
defer span.End()
// Logout user
authmiddleware.LogoutUser(ctx)
span.AddEvent("logged out user")
// Render the response
render.Status(r, http.StatusOK)
render.JSON(w, r, nil)
}
func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) {
ctx, span := tracing.StartSpan(r.Context(), "api.Signup")
defer span.End()
l := logging.FromCtx(ctx)
if h.config.GetPublicConfig().DisableCredentialsLogin {
l.Debug("credentials registration is disabled")
server.RenderForbidden(w, r)
return
}
// Get the user from the json body
type signupForm struct {
models.User
Password string `json:"password,omitempty"`
}
var form *signupForm
if err := render.DecodeJSON(r.Body, &form); err != nil {
err = fmt.Errorf("failed to parse form: %w", err)
server.RenderBadRequest(w, r, err)
return
}
span.AddEvent("parsed form")
// Get user and pass from form
user := &form.User
pass := form.Password
// Hash the password into the user
if err := h.users.SetPassword(ctx, user, pass); err != nil {
if errors.Is(err, errs.ErrInvalidUsernameOrPassword) {
server.RenderBadRequest(w, r, err)
} else {
server.RenderServerError(w, r, err)
}
return
}
span.AddEvent("hashed password")
// Create user
newUser, err := h.users.CreateUser(ctx, user)
if err != nil {
switch {
case errors.Is(err, errs.ErrUserExists) || errors.Is(err, errs.ErrInvalidUser):
server.RenderBadRequest(w, r, err)
case errors.Is(err, errs.ErrRegistrationDisabled):
l.Debug("failed to create user", "err", err)
server.RenderForbidden(w, r)
default:
server.RenderServerError(w, r, err)
}
return
}
span.AddEvent("created user")
// Render the response
render.Status(r, http.StatusCreated)
render.JSON(w, r, newUser)
}
func (h *APIHandler) CreateShort(w http.ResponseWriter, r *http.Request) {
ctx, span := tracing.StartSpan(r.Context(), "api.CreateShort")
defer span.End()
// Get the URL from the json body
var short *models.Short
if err := render.DecodeJSON(r.Body, &short); err != nil {
server.RenderBadRequest(w, r, err)
return
}
span.AddEvent("parsed form")
// Get user from context
user, ok := h.findUserOrRespond(w, r)
if !ok {
return
}
span.AddEvent("found user")
// Set the user
short.UserID = &user.ID
// Shorten URL
newShort, err := h.shorts.Shorten(ctx, short)
if err != nil {
if errors.Is(err, errs.ErrShortExists) || errors.Is(err, errs.ErrInvalidShort) {
server.RenderBadRequest(w, r, err)
} else {
server.RenderServerError(w, r, err)
}
return
}
span.AddEvent("created short")
shortResponse := newShortResponse(r, newShort)
span.AddEvent("created shorted response")
// Render the response
render.Status(r, http.StatusCreated)
render.JSON(w, r, shortResponse)
}
func (h *APIHandler) ListShorts(w http.ResponseWriter, r *http.Request) {
ctx, span := tracing.StartSpan(r.Context(), "api.ListShorts")
defer span.End()
// Get user from context
user, ok := h.findUserOrRespond(w, r)
if !ok {
return
}
span.AddEvent("found user")
// Get shorts
shorts, err := h.shorts.ListShorts(ctx, user)
if err != nil {
server.RenderServerError(w, r, err)
return
}
span.AddEvent("listed shorts")
shortsResponse := newShortResponseList(r, shorts)
span.AddEvent("created shorts response")
// Render the response
render.Status(r, http.StatusOK)
render.JSON(w, r, shortsResponse)
}
func (h *APIHandler) FindShort(w http.ResponseWriter, r *http.Request) {
_, span := tracing.StartSpan(r.Context(), "api.FindShort")
defer span.End()
// Find own short or respond
short, ok := h.findShortOrRespond(w, r)
if !ok {
return
}
span.AddEvent("found short")
shortResponse := newShortResponse(r, short)
span.AddEvent("created short response")
// Render the short
render.Status(r, http.StatusOK)
render.JSON(w, r, shortResponse)
}
func (h *APIHandler) DeleteShort(w http.ResponseWriter, r *http.Request) {
ctx, span := tracing.StartSpan(r.Context(), "api.DeleteShort")
defer span.End()
// Find own short or respond
short, ok := h.findShortOrRespond(w, r)
if !ok {
return
}
span.AddEvent("found short")
// Delete short
err := h.shorts.DeleteShort(ctx, short)
if err != nil {
server.RenderServerError(w, r, err)
return
}
span.AddEvent("deleted short")
// Deleted, return no content
render.NoContent(w, r)
}
func (h *APIHandler) ListShortLogs(w http.ResponseWriter, r *http.Request) {
ctx, span := tracing.StartSpan(r.Context(), "api.ListShortLogs")
defer span.End()
// Find own short or respond
short, ok := h.findShortOrRespond(w, r)
if !ok {
return
}
span.AddEvent("found short")
// Get logs
logs, err := h.shortLogs.ListLogs(ctx, short)
if err != nil {
server.RenderServerError(w, r, err)
return
}
span.AddEvent("listed logs")
// Render the response
render.Status(r, http.StatusOK)
render.JSON(w, r, logs)
}
func (h *APIHandler) ListSessions(w http.ResponseWriter, r *http.Request) {
ctx, span := tracing.StartSpan(r.Context(), "api.ListSessions")
// Get user from context
user, ok := h.findUserOrRespond(w, r)
if !ok {
return
}
span.AddEvent("found user")
sessions, err := authmiddleware.ListUserSessions(ctx, user)
if err != nil {
server.RenderServerError(w, r, err)
}
span.AddEvent("listed sessions")
// Render the response
render.Status(r, http.StatusOK)
render.JSON(w, r, sessions)
}
func (h *APIHandler) DeleteSession(w http.ResponseWriter, r *http.Request) {
ctx, span := tracing.StartSpan(r.Context(), "api.DeleteSession")
defer span.End()
l := logging.FromCtx(ctx)
// Get user from context
user, ok := h.findUserOrRespond(w, r)
if !ok {
return
}
span.AddEvent("found user")
// Get session token from request
sessionToken := chi.URLParam(r, "id")
// Delete session
err := authmiddleware.DeleteUserSession(ctx, user, sessionToken)
if err != nil {
if errors.Is(err, errs.ErrSessionDoesNotExist) {
l.Debug("could not delete session", "err", err)
server.RenderNotFound(w, r)
} else {
server.RenderServerError(w, r, err)
}
return
}
span.AddEvent("deleted session")
// Render the response
render.NoContent(w, r)
}
// ListTokens lists all tokens belonging to the user.
func (h *APIHandler) ListTokens(w http.ResponseWriter, r *http.Request) {
ctx, span := tracing.StartSpan(r.Context(), "api.ListTokens")
defer span.End()
// Get user from context
user, ok := h.findUserOrRespond(w, r)
if !ok {
return
}
span.AddEvent("found user")
// Get tokens
tokens, err := h.tokens.ListTokens(ctx, user)
if err != nil {
server.RenderServerError(w, r, err)
}
span.AddEvent("listed tokens")
// Render the response
render.Status(r, http.StatusOK)
render.JSON(w, r, tokens)
}
// CreateToken creates a new token for the user.
func (h *APIHandler) CreateToken(w http.ResponseWriter, r *http.Request) {
ctx, span := tracing.StartSpan(r.Context(), "api.CreateToken")
defer span.End()
type tokenNameForm struct {
Name string `json:"name"`
}
form := &tokenNameForm{}
// if error is EOF, it means the body is empty, so we can ignore it
if err := render.DecodeJSON(r.Body, form); err != nil && errors.Is(err, io.EOF) {
server.RenderBadRequest(w, r, err)
return
}
span.AddEvent("parsed form")
// Get user from context
user, ok := h.findUserOrRespond(w, r)
if !ok {
return
}
span.AddEvent("found user")
token, err := h.tokens.CreateToken(ctx, user, form.Name)
if err != nil {
server.RenderServerError(w, r, err)
return
}
span.AddEvent("created token")
// Render the response
render.Status(r, http.StatusCreated)
render.JSON(w, r, token)
}
// ChangeTokenName changes a token's name belonging to the user.
func (h *APIHandler) ChangeTokenName(w http.ResponseWriter, r *http.Request) {
ctx, span := tracing.StartSpan(r.Context(), "api.ChangeTokenName")
defer span.End()
type tokenNameForm struct {
Name string `json:"name"`
}
var form *tokenNameForm
if err := render.DecodeJSON(r.Body, &form); err != nil {
server.RenderBadRequest(w, r, err)
return
}
span.AddEvent("parsed form")
// Find token or respond
token, ok := h.findTokenOrRespond(w, r)
if !ok {
return
}
span.AddEvent("found token")
// Rename token
newToken, err := h.tokens.ChangeTokenName(ctx, token, form.Name)
if err != nil {
server.RenderServerError(w, r, err)
return
}
span.AddEvent("changed token name")
// Changed. Return the token
render.Status(r, http.StatusOK)
render.JSON(w, r, newToken)
}
// DeleteToken deletes a token belonging to the user.
func (h *APIHandler) DeleteToken(w http.ResponseWriter, r *http.Request) {
ctx, span := tracing.StartSpan(r.Context(), "api.DeleteToken")
defer span.End()
// Find token or respond
token, ok := h.findTokenOrRespond(w, r)
if !ok {
return
}
span.AddEvent("found token")
// Delete token
err := h.tokens.DeleteToken(ctx, token)
if err != nil {
server.RenderServerError(w, r, err)
return
}
span.AddEvent("deleted token")
// Deleted, return no content
render.NoContent(w, r)
}
// PublicConfig returns the public configuration of the server.
func (h *APIHandler) PublicConfig(w http.ResponseWriter, r *http.Request) {
_, span := tracing.StartSpan(r.Context(), "api.PublicConfig")
defer span.End()
// Get public config
config := h.config.GetPublicConfig()
span.AddEvent("got public config")
// Render the response
render.Status(r, http.StatusOK)
render.JSON(w, r, config)
}
// findUserOrRespond is a helper function that finds a user in the session,
// and returns it. If the user is not found, it returns nil and false.
func (h *APIHandler) findUserOrRespond(w http.ResponseWriter, r *http.Request) (user *models.User, ok bool) {
ctx := r.Context()
// Get user from context
user, ok = authmiddleware.UserFromCtx(ctx)
if !ok {
server.RenderServerError(w, r, errs.ErrInvalidUser)
return nil, false
}
return user, true
}
// findShortOrRespond is a helper function that finds a short specified in the request params,
// and checks if the user in the session is the same as the short's user. If it is, it returns
// the short and true. If it isn't, it returns nil and false.
func (h *APIHandler) findShortOrRespond(w http.ResponseWriter, r *http.Request) (short *models.Short, ok bool) { //nolint:dupl // This and the below function are similar, but different enough to not be refactored.
ctx := r.Context()
l := logging.FromCtx(ctx)
// Get short id from request
id := chi.URLParam(r, "id")
// Find short in storage
short, err := h.shorts.FindShortByID(ctx, id)
if err != nil {
// If the short doesn't exist or is invalid, return not found
if errors.Is(err, errs.ErrShortDoesNotExist) || errors.Is(err, errs.ErrInvalidShort) {
l.Debug("could not find short", "err", err)
server.RenderNotFound(w, r)
} else {
server.RenderServerError(w, r, err)
}
return nil, false
}
// Get user from context
user, ok := h.findUserOrRespond(w, r)
if !ok {
return nil, false
}
// If the session user does not match the short's user,
// return forbidden.
if user.ID != *short.UserID {
l.Debug("short's user does not match request user", slog.String("short_user", *short.UserID))
server.RenderForbidden(w, r)
return nil, false
}
return short, true
}
// findTokenOrRespond is a helper function that finds a token specified in the request params,
// and checks if the user in the session is the same as the tokens's user. If it is, it returns
// the token and true. If it isn't, it returns nil and false.
func (h *APIHandler) findTokenOrRespond(w http.ResponseWriter, r *http.Request) (token *models.Token, ok bool) { //nolint:dupl // This and the above function are similar, but different enough to not be refactored.
ctx := r.Context()
l := logging.FromCtx(ctx)
// Get token ID from request
id := chi.URLParam(r, "id")
// Find token in storage
token, err := h.tokens.FindTokenByID(ctx, id)
if err != nil {
// If the token doesn't exist or is invalid, return not found
if errors.Is(err, errs.ErrTokenDoesNotExist) || errors.Is(err, errs.ErrInvalidToken) {
l.Debug("could not find token", "err", err)
server.RenderNotFound(w, r)
} else {
server.RenderServerError(w, r, err)
}
return nil, false
}
// Get user from context
user, ok := h.findUserOrRespond(w, r)
if !ok {
return nil, false
}
// If the session user does not match the token's user,
// return NotFound.
if user.ID != *token.UserID {
l.Debug("token's user does not match request user", slog.String("token_user", *token.UserID))
server.RenderNotFound(w, r)
return nil, false
}
return token, true
}
type shortResponse struct {
*models.Short
ShortURL string `json:"shorturl"`
}
func newShortResponse(r *http.Request, short *models.Short) *shortResponse {
// Add `shorturl` to response so it can be used by third parties
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
shortURL := url.URL{
Scheme: scheme,
Host: r.Host,
Path: short.Name,
}
return &shortResponse{
Short: short,
ShortURL: shortURL.String(),
}
}
func newShortResponseList(r *http.Request, shorts []*models.Short) []*shortResponse {
shortResponses := make([]*shortResponse, len(shorts))
for i, short := range shorts {
shortResponses[i] = newShortResponse(r, short)
}
return shortResponses
}