Add database query status (and improved response), photos, likes, comments, bans

This commit is contained in:
Marco Realacci 2022-11-20 19:21:26 +01:00
parent 519ae22197
commit abbd5bc494
22 changed files with 1118 additions and 72 deletions

View file

@ -3,11 +3,12 @@ package main
import (
"errors"
"fmt"
"github.com/ardanlabs/conf"
"gopkg.in/yaml.v2"
"io"
"os"
"time"
"github.com/ardanlabs/conf"
"gopkg.in/yaml.v2"
)
// WebAPIConfiguration describes the web API configuration. This structure is automatically parsed by
@ -27,6 +28,9 @@ type WebAPIConfiguration struct {
DB struct {
Filename string `conf:"default:/tmp/decaf.db"`
}
Data struct {
Path string `conf:"default:/tmp/wasaphoto"`
}
}
// loadConfiguration creates a WebAPIConfiguration starting from flags, environment variables and configuration file.

View file

@ -28,17 +28,18 @@ import (
"database/sql"
"errors"
"fmt"
"github.com/notherealmarco/WASAPhoto/service/api"
"github.com/notherealmarco/WASAPhoto/service/database"
"github.com/notherealmarco/WASAPhoto/service/globaltime"
"github.com/ardanlabs/conf"
_ "github.com/mattn/go-sqlite3"
"github.com/sirupsen/logrus"
"math/rand"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/ardanlabs/conf"
_ "github.com/mattn/go-sqlite3"
"github.com/notherealmarco/WASAPhoto/service/api"
"github.com/notherealmarco/WASAPhoto/service/database"
"github.com/notherealmarco/WASAPhoto/service/globaltime"
"github.com/sirupsen/logrus"
)
// main is the program entry point. The only purpose of this function is to call run() and set the exit code if there is
@ -113,6 +114,7 @@ func run() error {
apirouter, err := api.New(api.Config{
Logger: logger,
Database: db,
DataPath: cfg.Data.Path,
})
if err != nil {
logger.WithError(err).Error("error creating the API server instance")

View file

@ -27,8 +27,9 @@ func (rt *_router) wrap(fn httpRouterHandler) func(http.ResponseWriter, *http.Re
auth, err := authorization.BuildAuth(r.Header.Get("Authorization"))
if err != nil {
auth = authorization.BuildAnonymous()
rt.baseLogger.WithError(err).Info("User not authorized")
return
// is not an error, just a not logged in user!
}
var ctx = reqcontext.RequestContext{

View file

@ -11,7 +11,20 @@ func (rt *_router) Handler() http.Handler {
rt.router.PUT("/users/:user_id/username", rt.wrap(rt.UpdateUsername))
rt.router.GET("/users/:user_id/followers", rt.wrap(rt.GetFollowers))
rt.router.GET("/users/:user_id/followers", rt.wrap(rt.GetFollowersFollowing))
rt.router.PUT("/users/:user_id/followers/:follower_uid", rt.wrap(rt.PutFollow))
rt.router.DELETE("/users/:user_id/followers/:follower_uid", rt.wrap(rt.DeleteFollow))
rt.router.GET("/users/:user_id/following", rt.wrap(rt.GetFollowersFollowing))
rt.router.PUT("/users/:user_id/bans/:ban_uid", rt.wrap(rt.PutBan))
rt.router.DELETE("/users/:user_id/bans/:ban_uid", rt.wrap(rt.DeleteBan))
rt.router.POST("/users/:user_id/photos", rt.wrap(rt.PostPhoto))
rt.router.GET("/users/:user_id/photos/:photo_id", rt.wrap(rt.GetPhoto))
rt.router.DELETE("/users/:user_id/photos/:photo_id", rt.wrap(rt.DeletePhoto))
rt.router.PUT("/users/:user_id/photos/:photo_id/likes/:liker_uid", rt.wrap(rt.PutDeleteLike))
rt.router.DELETE("/users/:user_id/photos/:photo_id/likes/:liker_uid", rt.wrap(rt.PutDeleteLike))
rt.router.GET("/", rt.getHelloWorld)
rt.router.GET("/context", rt.wrap(rt.getContextReply))

View file

@ -38,10 +38,11 @@ package api
import (
"errors"
"github.com/notherealmarco/WASAPhoto/service/database"
"github.com/julienschmidt/httprouter"
"github.com/sirupsen/logrus"
"net/http"
"github.com/julienschmidt/httprouter"
"github.com/notherealmarco/WASAPhoto/service/database"
"github.com/sirupsen/logrus"
)
// Config is used to provide dependencies and configuration to the New function.
@ -51,6 +52,8 @@ type Config struct {
// Database is the instance of database.AppDatabase where data are saved
Database database.AppDatabase
DataPath string
}
// Router is the package API interface representing an API handler builder
@ -82,6 +85,7 @@ func New(cfg Config) (Router, error) {
router: router,
baseLogger: cfg.Logger,
db: cfg.Database,
dataPath: cfg.DataPath,
}, nil
}
@ -93,4 +97,6 @@ type _router struct {
baseLogger logrus.FieldLogger
db database.AppDatabase
dataPath string
}

View file

@ -0,0 +1,31 @@
// This identity provider represents non logged-in users.
package authorization
import (
"github.com/notherealmarco/WASAPhoto/service/api/reqcontext"
"github.com/notherealmarco/WASAPhoto/service/database"
)
type AnonymousAuth struct {
}
func BuildAnonymous() *AnonymousAuth {
return &AnonymousAuth{}
}
func (u *AnonymousAuth) GetType() string {
return "Anonymous"
}
func (u *AnonymousAuth) Authorized(db database.AppDatabase) (reqcontext.AuthStatus, error) {
return reqcontext.UNAUTHORIZED, nil
}
func (u *AnonymousAuth) UserAuthorized(db database.AppDatabase, uid string) (reqcontext.AuthStatus, error) {
return reqcontext.UNAUTHORIZED, nil
}
func (u *AnonymousAuth) GetUserID() string {
return ""
}

View file

@ -29,7 +29,7 @@ func BuildBearer(header string) (*BearerAuth, error) {
return &BearerAuth{token: header[7:]}, nil
}
func (b *BearerAuth) GetToken() string {
func (b *BearerAuth) GetUserID() string {
return b.token
}

View file

@ -19,7 +19,7 @@ func BuildAuth(header string) (reqcontext.Authorization, error) {
return auth, nil
}
func SendAuthorizationError(f func(db database.AppDatabase, uid string) (reqcontext.AuthStatus, error), uid string, db database.AppDatabase, w http.ResponseWriter) bool {
func SendAuthorizationError(f func(db database.AppDatabase, uid string) (reqcontext.AuthStatus, error), uid string, db database.AppDatabase, w http.ResponseWriter, notFoundStatus int) bool {
auth, err := f(db, uid)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
@ -36,7 +36,7 @@ func SendAuthorizationError(f func(db database.AppDatabase, uid string) (reqcont
}
// requested user is not found -> 404 as the resource is not found
if auth == reqcontext.USER_NOT_FOUND {
w.WriteHeader(http.StatusNotFound)
w.WriteHeader(notFoundStatus)
return false
}
return true

70
service/api/bans.go Normal file
View file

@ -0,0 +1,70 @@
package api
import (
"net/http"
"github.com/julienschmidt/httprouter"
"github.com/notherealmarco/WASAPhoto/service/api/authorization"
"github.com/notherealmarco/WASAPhoto/service/api/helpers"
"github.com/notherealmarco/WASAPhoto/service/api/reqcontext"
"github.com/notherealmarco/WASAPhoto/service/database"
)
func (rt *_router) PutBan(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
uid := ps.ByName("user_id")
banned := ps.ByName("ban_uid")
// send error if the user has no permission to perform this action
if !authorization.SendAuthorizationError(ctx.Auth.UserAuthorized, uid, rt.db, w, http.StatusNotFound) {
return
}
if uid == banned {
helpers.SendBadRequest(w, "You cannot ban yourself", rt.baseLogger)
return
}
status, err := rt.db.BanUser(uid, banned)
if err != nil {
helpers.SendInternalError(err, "Database error: BanUser", w, rt.baseLogger)
return
}
if status == database.ERR_NOT_FOUND {
helpers.SendBadRequest(w, "You are trying to ban a non-existent user", rt.baseLogger)
return
}
if status == database.ERR_EXISTS {
w.WriteHeader(http.StatusNoContent)
return
}
helpers.SendStatus(http.StatusCreated, w, "Success", rt.baseLogger)
}
func (rt *_router) DeleteBan(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
uid := ps.ByName("user_id")
banned := ps.ByName("ban_uid")
// send error if the user has no permission to perform this action
if !authorization.SendAuthorizationError(ctx.Auth.UserAuthorized, uid, rt.db, w, http.StatusNotFound) {
return
}
status, err := rt.db.UnbanUser(uid, banned)
if err != nil {
helpers.SendInternalError(err, "Database error: UnbanUser", w, rt.baseLogger)
return
}
if status == database.ERR_NOT_FOUND {
helpers.SendNotFound(w, "User not banned", rt.baseLogger)
return
}
w.WriteHeader(http.StatusNoContent)
}

167
service/api/comments.go Normal file
View file

@ -0,0 +1,167 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/julienschmidt/httprouter"
"github.com/notherealmarco/WASAPhoto/service/api/authorization"
"github.com/notherealmarco/WASAPhoto/service/api/helpers"
"github.com/notherealmarco/WASAPhoto/service/api/reqcontext"
"github.com/notherealmarco/WASAPhoto/service/database"
)
type reqbody struct {
UID string `json:"user_id"`
Comment string `json:"comment"`
}
func (rt *_router) GetComments(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
// get the user id from the url
uid := ps.ByName("user_id")
photo_id, err := strconv.ParseInt(ps.ByName("photo_id"), 10, 64)
if err != nil {
helpers.SendBadRequest(w, "Invalid photo id", rt.baseLogger)
return
}
// send 404 if the user does not exist
if !helpers.VerifyUserOrNotFound(rt.db, uid, w, rt.baseLogger) {
return
}
// get the user's comments
success, comments, err := rt.db.GetComments(uid, photo_id)
if err != nil {
helpers.SendInternalError(err, "Database error: GetComments", w, rt.baseLogger)
return
}
if success == database.ERR_NOT_FOUND {
helpers.SendNotFound(w, "User or photo not found", rt.baseLogger)
return
}
// send the response
err = json.NewEncoder(w).Encode(comments)
if err != nil {
helpers.SendInternalError(err, "Error encoding comments", w, rt.baseLogger)
return
}
}
func (rt *_router) PostComment(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
uid := ps.ByName("user_id")
photo_id, err := strconv.ParseInt(ps.ByName("photo_id"), 10, 64)
if err != nil {
helpers.SendBadRequestError(err, "Bad photo_id", w, rt.baseLogger)
return
}
// get the comment from the request
var request_body reqbody
if !helpers.DecodeJsonOrBadRequest(r.Body, w, &request_body, rt.baseLogger) {
return
}
// check if the user is authorized to post a comment
if !authorization.SendAuthorizationError(ctx.Auth.UserAuthorized, request_body.UID, rt.db, w, http.StatusBadRequest) {
return
}
// add the comment to the database
success, err := rt.db.PostComment(uid, photo_id, request_body.UID, request_body.Comment)
if err != nil {
helpers.SendInternalError(err, "Database error: PostComment", w, rt.baseLogger)
return
}
// if user or photo does not exist, send 404
if success == database.ERR_NOT_FOUND {
helpers.SendNotFound(w, "User or photo not found", rt.baseLogger)
return
}
// send the response
helpers.SendStatus(http.StatusCreated, w, "Comment created with success", rt.baseLogger)
}
func (rt *_router) DeleteComment(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
uid := ps.ByName("user_id")
photo_id, err := strconv.ParseInt(ps.ByName("photo_id"), 10, 64)
if err != nil {
helpers.SendBadRequestError(err, "Bad photo_id", w, rt.baseLogger)
return
}
comment_id, err := strconv.ParseInt(ps.ByName("comment_id"), 10, 64)
if err != nil {
helpers.SendBadRequestError(err, "Bad comment_id", w, rt.baseLogger)
return
}
// Check if the user is authorized to delete that comment
// (only the user who posted the comment or the owner of the photo can delete it)
status, comment_owner, err := rt.db.GetCommentOwner(uid, photo_id, comment_id)
if err != nil {
helpers.SendInternalError(err, "Database error: GetCommentOwner", w, rt.baseLogger)
return
}
if status == database.ERR_NOT_FOUND {
helpers.SendNotFound(w, "Resource not found", rt.baseLogger)
return
}
// The authorized user must be either comment_owner or uid
owner_auth, err := ctx.Auth.UserAuthorized(rt.db, comment_owner)
if err != nil {
helpers.SendInternalError(err, "Error checking authorization", w, rt.baseLogger)
return
}
// If the status is UNAUTHORIZED, this means that the Authorization header is missing or invalid
// We don't need to check if user is 'uid' and we can send the error
if owner_auth == reqcontext.UNAUTHORIZED {
helpers.SendStatus(http.StatusUnauthorized, w, "Unauthorized", rt.baseLogger)
}
if owner_auth != reqcontext.AUTHORIZED {
// Authorized user is not the owner of the comment
// let's check if it's the owner of the photo
if !authorization.SendAuthorizationError(ctx.Auth.UserAuthorized, uid, rt.db, w, http.StatusForbidden) {
// The authorized user is not the owner of the photo, so we sent an error
return
}
// If it is authorized, the user can delete the comment.
// (else the status must be FORBIDDEN. It can't be UNAUTHORIZED because we already checked it
// and it can't be NOT_FOUND because we used it before to get the comment owner)
}
// Delete the comment
_, err = rt.db.DeleteComment(uid, photo_id, comment_id)
if err != nil {
helpers.SendInternalError(err, "Database error: DeleteComment", w, rt.baseLogger)
return
}
// We don't need to check the status because if the comment didn't exist
// we'd have already returned an error when getting the comment owner
// so we know the comment existed and was deleted, and we can safely send 204
w.WriteHeader(http.StatusNoContent)
}

View file

@ -3,32 +3,56 @@ package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/julienschmidt/httprouter"
"github.com/notherealmarco/WASAPhoto/service/api/authorization"
"github.com/notherealmarco/WASAPhoto/service/api/helpers"
"github.com/notherealmarco/WASAPhoto/service/api/reqcontext"
"github.com/notherealmarco/WASAPhoto/service/database"
"github.com/notherealmarco/WASAPhoto/service/structures"
)
func (rt *_router) GetFollowers(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
func (rt *_router) GetFollowersFollowing(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
uid := ps.ByName("user_id")
if !helpers.VerifyUserOrNotFound(rt.db, uid, w) {
if !helpers.VerifyUserOrNotFound(rt.db, uid, w, rt.baseLogger) {
return
}
followers, err := rt.db.GetUserFollowers(uid)
var users *[]structures.UIDName
var err error
var status database.QueryResult
// Check if client is asking for followers or following
if strings.HasSuffix(r.URL.Path, "/followers") {
// Get the followers from the database
status, users, err = rt.db.GetUserFollowers(uid)
} else {
// Get the following users from the database
status, users, err = rt.db.GetUserFollowing(uid)
}
// Send a 500 response if there was an error
if err != nil {
w.WriteHeader(http.StatusInternalServerError) // todo: is not ok, maybe let's use a helper
helpers.SendInternalError(err, "Database error: GetUserFollowers", w, rt.baseLogger)
return
}
// Send a 404 response if the user was not found
if status == database.ERR_NOT_FOUND {
helpers.SendNotFound(w, "User not found", rt.baseLogger)
return
}
// Send the users to the client
w.Header().Set("content-type", "application/json")
err = json.NewEncoder(w).Encode(&followers)
err = json.NewEncoder(w).Encode(users)
// Send a 500 response if there was an error
if err != nil {
w.WriteHeader(http.StatusInternalServerError) // todo: is not ok
helpers.SendInternalError(err, "Error encoding json", w, rt.baseLogger)
return
}
}
@ -38,12 +62,52 @@ func (rt *_router) PutFollow(w http.ResponseWriter, r *http.Request, ps httprout
uid := ps.ByName("user_id")
followed := ps.ByName("follower_uid")
err := rt.db.FollowUser(uid, followed)
if err != nil {
w.WriteHeader(http.StatusInternalServerError) // todo: is not ok, maybe let's use a helper
// send error if the user has no permission to perform this action
if !authorization.SendAuthorizationError(ctx.Auth.UserAuthorized, uid, rt.db, w, http.StatusNotFound) {
return
}
w.WriteHeader(http.StatusNoContent) // todo: change to 204 also in API spec
status, err := rt.db.FollowUser(uid, followed)
if err != nil {
helpers.SendInternalError(err, "Database error: FollowUser", w, rt.baseLogger)
return
}
if status == database.ERR_EXISTS {
w.WriteHeader(http.StatusNoContent)
return
}
if status == database.ERR_NOT_FOUND {
helpers.SendBadRequest(w, "You are trying to follow a non-existent user", rt.baseLogger)
return
}
helpers.SendStatus(http.StatusCreated, w, "Success", rt.baseLogger)
}
func (rt *_router) DeleteFollow(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
uid := ps.ByName("user_id")
followed := ps.ByName("follower_uid")
// send error if the user has no permission to perform this action
if !authorization.SendAuthorizationError(ctx.Auth.UserAuthorized, uid, rt.db, w, http.StatusNotFound) {
return
}
status, err := rt.db.UnfollowUser(uid, followed)
if err != nil {
helpers.SendInternalError(err, "Database error: UnfollowUser", w, rt.baseLogger)
return
}
if status == database.ERR_NOT_FOUND {
helpers.SendNotFound(w, "User not found", rt.baseLogger)
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -6,6 +6,7 @@ import (
"net/http"
"github.com/notherealmarco/WASAPhoto/service/database"
"github.com/notherealmarco/WASAPhoto/service/structures"
"github.com/sirupsen/logrus"
)
@ -19,24 +20,72 @@ func DecodeJsonOrBadRequest(r io.Reader, w http.ResponseWriter, v interface{}, l
return true
}
func VerifyUserOrNotFound(db database.AppDatabase, uid string, w http.ResponseWriter) bool {
func VerifyUserOrNotFound(db database.AppDatabase, uid string, w http.ResponseWriter, l logrus.FieldLogger) bool {
user_exists, err := db.UserExists(uid)
if err != nil {
SendInternalError(err, "Error verifying user existence", w, nil)
SendInternalError(err, "Error verifying user existence", w, l)
return false
}
if !user_exists {
w.WriteHeader(http.StatusNotFound)
SendNotFound(w, "User not found", l)
return false
}
return true
}
func SendStatus(httpStatus int, w http.ResponseWriter, description string, l logrus.FieldLogger) {
w.WriteHeader(httpStatus)
err := json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
if err != nil {
l.WithError(err).Error("Error encoding json")
//todo: empty response?
}
}
func SendNotFound(w http.ResponseWriter, description string, l logrus.FieldLogger) {
w.WriteHeader(http.StatusNotFound)
err := json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
if err != nil {
l.WithError(err).Error("Error encoding json")
//todo: empty response?
}
}
func SendBadRequest(w http.ResponseWriter, description string, l logrus.FieldLogger) {
w.WriteHeader(http.StatusBadRequest)
err := json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
if err != nil {
l.WithError(err).Error("Error encoding json")
//todo: empty response?
}
}
func SendBadRequestError(err error, description string, w http.ResponseWriter, l logrus.FieldLogger) {
w.WriteHeader(http.StatusBadRequest)
l.WithError(err).Error(description)
err = json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
if err != nil {
l.WithError(err).Error("Error encoding json")
//todo: empty response?
}
}
func SendInternalError(err error, description string, w http.ResponseWriter, l logrus.FieldLogger) {
w.WriteHeader(http.StatusInternalServerError)
l.WithError(err).Error(description)
w.Write([]byte(description)) // todo: maybe in json. But it's not important to send the full error to the client
err = json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
if err != nil {
l.WithError(err).Error("Error encoding json")
//todo: empty response?
}
}
func RollbackOrLogError(tx database.DBTransaction, l logrus.FieldLogger) {
err := tx.Rollback()
if err != nil {
l.WithError(err).Error("Error rolling back transaction")
}
}

96
service/api/likes.go Normal file
View file

@ -0,0 +1,96 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/julienschmidt/httprouter"
"github.com/notherealmarco/WASAPhoto/service/api/authorization"
"github.com/notherealmarco/WASAPhoto/service/api/helpers"
"github.com/notherealmarco/WASAPhoto/service/api/reqcontext"
"github.com/notherealmarco/WASAPhoto/service/database"
)
func (rt *_router) GetLikes(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
// get the user id from the url
uid := ps.ByName("user_id")
photo_id, err := strconv.ParseInt(ps.ByName("photo_id"), 10, 64)
if err != nil {
helpers.SendBadRequest(w, "Invalid photo id", rt.baseLogger)
return
}
// send 404 if the user does not exist
if !helpers.VerifyUserOrNotFound(rt.db, uid, w, rt.baseLogger) {
return
}
// get the user's likes
success, likes, err := rt.db.GetPhotoLikes(uid, photo_id)
if err != nil {
helpers.SendInternalError(err, "Database error: GetLikes", w, rt.baseLogger)
return
}
if success == database.ERR_NOT_FOUND {
helpers.SendNotFound(w, "User or photo not found", rt.baseLogger)
return
}
// send the response
err = json.NewEncoder(w).Encode(likes)
if err != nil {
helpers.SendInternalError(err, "Error encoding response", w, rt.baseLogger)
return
}
}
func (rt *_router) PutDeleteLike(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
uid := ps.ByName("user_id")
photo_id, err := strconv.ParseInt(ps.ByName("photo_id"), 10, 64)
if err != nil {
helpers.SendBadRequestError(err, "Bad photo_id", w, rt.baseLogger)
return
}
liker_uid := ps.ByName("liker_uid")
if !authorization.SendAuthorizationError(ctx.Auth.UserAuthorized, liker_uid, rt.db, w, http.StatusBadRequest) {
return
}
var success database.QueryResult
// If the request is a PUT, then we like the photo
if r.Method == "PUT" {
success, err = rt.db.LikePhoto(uid, photo_id, liker_uid)
} else { // Request is a DELETE, so we unlike the photo
success, err = rt.db.UnlikePhoto(uid, photo_id, liker_uid)
}
if err != nil {
helpers.SendInternalError(err, "Database error", w, rt.baseLogger)
return
}
if success == database.ERR_NOT_FOUND {
helpers.SendBadRequest(w, "User or photo not found", rt.baseLogger)
return
}
// User already liked the photo
if success == database.ERR_EXISTS {
w.WriteHeader(http.StatusNoContent)
return
}
// User liked the photo successfully
helpers.SendStatus(http.StatusCreated, w, "Success", rt.baseLogger)
}

131
service/api/photos.go Normal file
View file

@ -0,0 +1,131 @@
package api
import (
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"github.com/julienschmidt/httprouter"
"github.com/notherealmarco/WASAPhoto/service/api/authorization"
"github.com/notherealmarco/WASAPhoto/service/api/helpers"
"github.com/notherealmarco/WASAPhoto/service/api/reqcontext"
)
func (rt *_router) PostPhoto(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
defer r.Body.Close()
uid := ps.ByName("user_id")
// send error if the user has no permission to perform this action
if !authorization.SendAuthorizationError(ctx.Auth.UserAuthorized, uid, rt.db, w, http.StatusNotFound) {
return
}
transaction, photo_id, err := rt.db.PostPhoto(uid)
if err != nil {
helpers.SendInternalError(err, "Database error: PostPhoto", w, rt.baseLogger)
return
}
path := rt.dataPath + "/photos/" + uid + "/" + strconv.FormatInt(photo_id, 10) + ".jpg"
// todo: we should check if the body is a valid jpg image
if err = os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { // perms = 511
helpers.SendInternalError(err, "Error creating directory", w, rt.baseLogger)
return
}
file, err := os.Create(path)
if err != nil {
helpers.SendInternalError(err, "Error creating file", w, rt.baseLogger)
helpers.RollbackOrLogError(transaction, rt.baseLogger)
return
}
if _, err = io.Copy(file, r.Body); err != nil {
helpers.SendInternalError(err, "Error writing the file", w, rt.baseLogger)
helpers.RollbackOrLogError(transaction, rt.baseLogger)
return
}
if err = file.Close(); err != nil {
helpers.SendInternalError(err, "Error closing file", w, rt.baseLogger)
helpers.RollbackOrLogError(transaction, rt.baseLogger)
}
err = transaction.Commit()
if err != nil {
helpers.SendInternalError(err, "Error committing transaction", w, rt.baseLogger)
//todo: should I roll back?
return
}
helpers.SendStatus(http.StatusCreated, w, "Photo uploaded", rt.baseLogger)
}
func (rt *_router) GetPhoto(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
uid := ps.ByName("user_id")
photo_id := ps.ByName("photo_id")
if !helpers.VerifyUserOrNotFound(rt.db, uid, w, rt.baseLogger) {
return
}
path := rt.dataPath + "/photos/" + uid + "/" + photo_id + ".jpg"
file, err := os.Open(path)
if err != nil {
helpers.SendNotFound(w, "Photo not found", rt.baseLogger)
return
}
defer file.Close()
io.Copy(w, file)
}
func (rt *_router) DeletePhoto(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
uid := ps.ByName("user_id")
photo_id := ps.ByName("photo_id")
// photo id to int64
photo_id_int, err := strconv.ParseInt(photo_id, 10, 64)
if err != nil {
helpers.SendBadRequestError(err, "Bad photo id", w, rt.baseLogger)
return
}
// send error if the user has no permission to perform this action (only the author can delete a photo)
if !authorization.SendAuthorizationError(ctx.Auth.UserAuthorized, uid, rt.db, w, http.StatusNotFound) {
return
}
deleted, err := rt.db.DeletePhoto(uid, photo_id_int)
if err != nil {
helpers.SendInternalError(err, "Error deleting photo from database", w, rt.baseLogger)
return
} //todo: maybe let's use a transaction also here
if !deleted {
helpers.SendNotFound(w, "Photo not found", rt.baseLogger)
return
}
path := rt.dataPath + "/photos/" + uid + "/" + photo_id + ".jpg"
if err := os.Remove(path); err != nil {
helpers.SendInternalError(err, "Error deleting file", w, rt.baseLogger)
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -13,7 +13,7 @@ import (
func (rt *_router) UpdateUsername(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
uid := ps.ByName("user_id")
if !authorization.SendAuthorizationError(ctx.Auth.UserAuthorized, uid, rt.db, w) {
if !authorization.SendAuthorizationError(ctx.Auth.UserAuthorized, uid, rt.db, w, http.StatusNotFound) {
return
}
var req structures.UserDetails

View file

@ -19,7 +19,7 @@ const (
UNAUTHORIZED = 1
FORBIDDEN = 2
USER_NOT_FOUND = 3
)
) // todo: here?
// RequestContext is the context of the request, for request-dependent parameters
type RequestContext struct {
@ -34,6 +34,7 @@ type RequestContext struct {
type Authorization interface {
GetType() string
Authorized(db database.AppDatabase) (bool, error)
GetUserID() string
Authorized(db database.AppDatabase) (AuthStatus, error)
UserAuthorized(db database.AppDatabase, uid string) (AuthStatus, error)
}

View file

@ -40,25 +40,42 @@ import (
// AppDatabase is the high level interface for the DB
type AppDatabase interface {
CreateUser(name string) (string, error)
UserExists(uid string) (bool, error)
GetUserID(name string) (string, error)
UpdateUsername(uid, name string) error
CreateUser(name string) (string, error)
GetUserFollowers(uid string) ([]structures.UIDName, error)
FollowUser(uid string, follow string) error
UnfollowUser(uid string, unfollow string) error
BanUser(uid string, ban string) error
UnbanUser(uid string, unban string) error
PostPhoto(uid string) (int64, error)
LikePhoto(uid string, photo int64) error
UnlikePhoto(uid string, photo int64) error
UpdateUsername(uid, name string) error
GetUserFollowers(uid string) (QueryResult, *[]structures.UIDName, error) // todo: maybe use a pointer to a slice?
GetUserFollowing(uid string) (QueryResult, *[]structures.UIDName, error)
FollowUser(uid string, follow string) (QueryResult, error)
UnfollowUser(uid string, unfollow string) (QueryResult, error)
BanUser(uid string, ban string) (QueryResult, error)
UnbanUser(uid string, unban string) (QueryResult, error)
PostPhoto(uid string) (DBTransaction, int64, error)
DeletePhoto(uid string, photo int64) (bool, error)
GetPhotoLikes(uid string, photo int64) (QueryResult, *[]structures.UIDName, error)
LikePhoto(uid string, photo int64, liker_uid string) (QueryResult, error)
UnlikePhoto(uid string, photo int64, liker_uid string) (QueryResult, error)
GetUserProfile(uid string) (*UserProfile, error)
GetComments(uid string, photo_id int64) (QueryResult, *[]structures.Comment, error)
PostComment(uid string, photo_id int64, comment_user string, comment string) (QueryResult, error)
DeleteComment(uid string, photo_id int64, comment_id int64) (QueryResult, error)
GetCommentOwner(uid string, photo_id int64, comment_id int64) (QueryResult, string, error)
Ping() error
}
type DBTransaction interface {
Commit() error
Rollback() error
}
type appdbimpl struct {
c *sql.DB
}

View file

@ -0,0 +1,112 @@
package database
import (
"time"
"github.com/notherealmarco/WASAPhoto/service/database/db_errors"
"github.com/notherealmarco/WASAPhoto/service/structures"
)
func (db *appdbimpl) PostComment(uid string, photo_id int64, comment_user string, comment string) (QueryResult, error) {
// Check if the photo exists, as API specification requires
// photos to be identified also by the user who posted them.
// But our DB implementation only requires the photo id.
exists, err := db.photoExists(uid, photo_id)
if err != nil || !exists {
return ERR_NOT_FOUND, err
}
_, err = db.c.Exec(`PRAGMA foreign_keys = ON;
INSERT INTO "comments" ("user", "photo", "comment", "date") VALUES (?, ?, ?, ?)`, comment_user, photo_id, comment, time.Now().Format(time.RFC3339))
// todo: we don't actually need it, it's already done before
if db_errors.ForeignKeyViolation(err) {
return ERR_NOT_FOUND, nil
}
if err != nil {
return ERR_INTERNAL, err
}
return SUCCESS, nil
}
func (db *appdbimpl) GetCommentOwner(uid string, photo_id int64, comment_id int64) (QueryResult, string, error) {
// Check if the photo exists, as it exist but have no comments
exists, err := db.photoExists(uid, photo_id)
if err != nil || !exists {
return ERR_NOT_FOUND, "", err
}
var comment_user string
err = db.c.QueryRow(`SELECT "user" FROM "comments" WHERE "photo" = ? AND "id" = ?`, photo_id, comment_id).Scan(&comment_user)
if db_errors.EmptySet(err) {
return ERR_NOT_FOUND, "", nil
}
if err != nil {
return ERR_INTERNAL, "", err
}
return SUCCESS, comment_user, nil
}
func (db *appdbimpl) DeleteComment(uid string, photo_id int64, comment_id int64) (QueryResult, error) {
// Check if the photo exists, as API specification requires
// photos to be identified also by the user who posted them.
// But our DB implementation only requires the photo id.
exists, err := db.photoExists(uid, photo_id)
if err != nil || !exists {
return ERR_NOT_FOUND, err
}
res, err := db.c.Exec(`DELETE FROM "comments" WHERE "photo" = ? AND "id" = ?`, photo_id, comment_id)
if err != nil {
return ERR_INTERNAL, err
}
rows, err := res.RowsAffected()
if err != nil {
return ERR_INTERNAL, err
}
if rows == 0 {
return ERR_NOT_FOUND, nil
}
return SUCCESS, nil
}
func (db *appdbimpl) GetComments(uid string, photo_id int64) (QueryResult, *[]structures.Comment, error) {
// Check if the photo exists, as it exist but have no comments
exists, err := db.photoExists(uid, photo_id)
if err != nil || !exists {
return ERR_NOT_FOUND, nil, err
}
rows, err := db.c.Query(`SELECT "id", "user", "comment", "date" FROM "comments" WHERE "photo" = ?`, photo_id)
if err != nil {
return ERR_INTERNAL, nil, err
}
defer rows.Close()
comments := make([]structures.Comment, 0)
for rows.Next() {
var c structures.Comment
err = rows.Scan(&c.CommentID, &c.UID, &c.Comment, &c.Date)
if err != nil {
return ERR_INTERNAL, nil, err
}
comments = append(comments, c)
}
return SUCCESS, &comments, nil
}

View file

@ -1,6 +1,12 @@
package database
import "time"
import (
"database/sql"
"time"
"github.com/notherealmarco/WASAPhoto/service/database/db_errors"
"github.com/notherealmarco/WASAPhoto/service/structures"
)
type Photo struct {
ID int64
@ -16,17 +22,63 @@ type UserProfile struct {
Photos []Photo
}
type QueryResult int // todo: move to a separate file
const (
SUCCESS = 0
ERR_NOT_FOUND = 1
ERR_EXISTS = 2
ERR_INTERNAL = 3
)
type dbtransaction struct {
c *sql.Tx
}
func (tx *dbtransaction) Commit() error {
return tx.c.Commit()
}
func (tx *dbtransaction) Rollback() error {
return tx.c.Rollback()
}
// Post a new photo
func (db *appdbimpl) PostPhoto(uid string) (int64, error) {
res, err := db.c.Exec(`INSERT INTO "photos" ("user", "date") VALUES (?, ?)`, uid, time.Now().Format(time.RFC3339))
func (db *appdbimpl) PostPhoto(uid string) (DBTransaction, int64, error) {
tx, err := db.c.Begin()
if err != nil {
return 0, err
return nil, 0, err
}
res, err := tx.Exec(`INSERT INTO "photos" ("user", "date") VALUES (?, ?)`, uid, time.Now().Format(time.RFC3339))
if err != nil {
tx.Rollback() // error ?
return nil, 0, err
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
tx.Rollback() // error ?
return nil, 0, err
}
return id, nil
return &dbtransaction{
c: tx,
}, id, nil
}
// Delete a photo, returns true if the photo was deleted and false if it did not exist
func (db *appdbimpl) DeletePhoto(uid string, photo int64) (bool, error) {
res, err := db.c.Exec(`DELETE FROM "photos" WHERE "id" = ? AND "user" = ?`, photo, uid)
if err != nil {
return false, err
}
rows, err := res.RowsAffected()
if err != nil {
return false, err
}
return rows > 0, nil
}
// Get user profile, including username, followers, following, and photos
@ -58,7 +110,7 @@ func (db *appdbimpl) GetUserProfile(uid string) (*UserProfile, error) {
return nil, err
}
var photos []Photo
photos := make([]Photo, 0)
for rows.Next() {
var id int64
var date string
@ -75,14 +127,105 @@ func (db *appdbimpl) GetUserProfile(uid string) (*UserProfile, error) {
return &UserProfile{uid, name, followers, following, photos}, nil
}
// Check if a given photo owned by a given user exists
func (db *appdbimpl) photoExists(uid string, photo int64) (bool, error) {
var cnt int64
err := db.c.QueryRow(`SELECT COUNT(*) FROM "photos" WHERE "id" = ? AND "user" = ?`, photo, uid).Scan(&cnt)
if err != nil {
return false, err
}
return cnt > 0, nil
}
// Get the list of users who liked a photo
func (db *appdbimpl) GetPhotoLikes(uid string, photo int64) (QueryResult, *[]structures.UIDName, error) {
// Check if the photo exists, as it could exist but have no likes
exists, err := db.photoExists(uid, photo)
if err != nil {
return ERR_INTERNAL, nil, err
}
if !exists {
return ERR_NOT_FOUND, nil, nil
}
rows, err := db.c.Query(`SELECT "users.uid", "users.name" FROM "likes", "users"
WHERE "likes.photo_id" = ?
AND "likes.user" = "users.uid"`, photo)
if err != nil {
return ERR_INTERNAL, nil, err
}
likes := make([]structures.UIDName, 0)
for rows.Next() {
var uid string
var name string
err = rows.Scan(&uid, &name)
if err != nil {
return ERR_INTERNAL, nil, err
}
likes = append(likes, structures.UIDName{UID: uid, Name: name})
}
return SUCCESS, &likes, nil
}
// Like a photo
func (db *appdbimpl) LikePhoto(uid string, photo int64) error {
_, err := db.c.Exec(`INSERT INTO "likes" ("user", "photo_id") VALUES (?, ?)`, uid, photo)
return err
func (db *appdbimpl) LikePhoto(uid string, photo int64, liker_uid string) (QueryResult, error) {
// Check if the photo exists, as API specification requires
// photos to be identified also by the user who posted them.
// But our DB implementation only requires the photo id.
exists, err := db.photoExists(uid, photo)
if err != nil || !exists {
return ERR_NOT_FOUND, err
}
_, err = db.c.Exec(`PRAGMA foreign_keys = ON;
INSERT INTO "likes" ("user", "photo_id") VALUES (?, ?)`, liker_uid, photo)
// The photo exists, but the user already liked it
if db_errors.UniqueViolation(err) {
return ERR_EXISTS, nil
}
if db_errors.ForeignKeyViolation(err) {
return ERR_NOT_FOUND, nil
}
if err != nil {
return ERR_INTERNAL, err
}
return SUCCESS, nil
}
// Unlike a photo
func (db *appdbimpl) UnlikePhoto(uid string, photo int64) error {
_, err := db.c.Exec(`DELETE FROM "likes" WHERE "user" = ? AND "photo_id" = ?`, uid, photo)
return err
func (db *appdbimpl) UnlikePhoto(uid string, photo int64, liker_uid string) (QueryResult, error) {
// Check if the photo exists, as API specification requires
// photos to be identified also by the user who posted them.
// But our DB implementation only requires the photo id.
exists, err := db.photoExists(uid, photo)
if err != nil || !exists {
return ERR_NOT_FOUND, err
}
res, err := db.c.Exec(`DELETE FROM "likes" WHERE "user" = ? AND "photo_id" = ?`, liker_uid, photo)
if err != nil {
return ERR_INTERNAL, err
}
rows, err := res.RowsAffected()
if err != nil {
return ERR_INTERNAL, err
}
if rows == 0 {
return ERR_NOT_FOUND, nil
}
return SUCCESS, nil
}

View file

@ -1,6 +1,8 @@
package database
import (
"database/sql"
"github.com/gofrs/uuid"
"github.com/notherealmarco/WASAPhoto/service/database/db_errors"
"github.com/notherealmarco/WASAPhoto/service/structures"
@ -46,10 +48,62 @@ func (db *appdbimpl) UpdateUsername(uid string, name string) error {
}
// Get user followers
func (db *appdbimpl) GetUserFollowers(uid string) ([]structures.UIDName, error) {
func (db *appdbimpl) GetUserFollowers(uid string) (QueryResult, *[]structures.UIDName, error) {
// user may exist but have no followers
exists, err := db.UserExists(uid)
if err != nil {
return ERR_INTERNAL, nil, err
}
if !exists {
return ERR_NOT_FOUND, nil, nil
}
rows, err := db.c.Query(`SELECT "follower", "user.name" FROM "follows", "users"
WHERE "follows.follower" = "users.uid"
AND "followed" = ?`, uid)
followers, err := db.uidNameQuery(rows, err)
if err != nil {
return ERR_INTERNAL, nil, err
}
return SUCCESS, followers, nil
}
// Get user following
func (db *appdbimpl) GetUserFollowing(uid string) (QueryResult, *[]structures.UIDName, error) {
// user may exist but have no followers
exists, err := db.UserExists(uid)
if err != nil {
return ERR_INTERNAL, nil, err
}
if !exists {
return ERR_NOT_FOUND, nil, nil
}
rows, err := db.c.Query(`SELECT "followed", "user.name" FROM "follows", "users"
WHERE "follows.followed" = "users.uid"
AND "follower" = ?`, uid)
following, err := db.uidNameQuery(rows, err)
if err != nil {
return ERR_INTERNAL, nil, err
}
return SUCCESS, following, nil
}
// Evaluates a query that returns two columns: uid and name
func (db *appdbimpl) uidNameQuery(rows *sql.Rows, err error) (*[]structures.UIDName, error) {
if err != nil {
return nil, err
}
@ -64,29 +118,88 @@ func (db *appdbimpl) GetUserFollowers(uid string) ([]structures.UIDName, error)
}
followers = append(followers, structures.UIDName{UID: uid, Name: name})
}
return followers, nil
return &followers, nil
}
// Follow a user
func (db *appdbimpl) FollowUser(uid string, follow string) error {
_, err := db.c.Exec(`INSERT INTO "follows" ("follower", "followed") VALUES (?, ?)`, uid, follow)
return err
func (db *appdbimpl) FollowUser(uid string, follow string) (QueryResult, error) {
_, err := db.c.Exec(`PRAGMA foreign_keys = ON;
INSERT INTO "follows" ("follower", "followed") VALUES (?, ?)`, uid, follow)
if db_errors.UniqueViolation(err) {
return ERR_EXISTS, nil
}
if db_errors.ForeignKeyViolation(err) {
return ERR_NOT_FOUND, nil
}
if err != nil {
return ERR_INTERNAL, err
}
return SUCCESS, nil
}
// Unfollow a user
func (db *appdbimpl) UnfollowUser(uid string, unfollow string) error {
_, err := db.c.Exec(`DELETE FROM "follows" WHERE "follower" = ? AND "followed" = ?`, uid, unfollow)
return err
} //todo: should return boolean or something similar
func (db *appdbimpl) UnfollowUser(uid string, unfollow string) (QueryResult, error) {
res, err := db.c.Exec(`DELETE FROM "follows" WHERE "follower" = ? AND "followed" = ?`, uid, unfollow)
if err != nil {
return ERR_INTERNAL, err
}
rows, err := res.RowsAffected()
if err != nil {
return ERR_INTERNAL, err
}
if rows == 0 {
return ERR_NOT_FOUND, nil
}
return SUCCESS, nil
}
// Ban a user
func (db *appdbimpl) BanUser(uid string, ban string) error {
_, err := db.c.Exec(`INSERT INTO "bans" ("user", "ban") VALUES (?, ?)`, uid, ban)
return err
func (db *appdbimpl) BanUser(uid string, ban string) (QueryResult, error) {
_, err := db.c.Exec(`PRAGMA foreign_keys = ON;
INSERT INTO "bans" ("user", "ban") VALUES (?, ?)`, uid, ban)
// The user is already banned by this user
if db_errors.UniqueViolation(err) {
return ERR_EXISTS, nil
}
// One of the users does not exist
if db_errors.ForeignKeyViolation(err) {
return ERR_NOT_FOUND, nil
}
// Other error
if err != nil {
return ERR_INTERNAL, err
}
return SUCCESS, nil
}
// Unban a user
func (db *appdbimpl) UnbanUser(uid string, unban string) error {
_, err := db.c.Exec(`DELETE FROM "bans" WHERE "user" = ? AND "ban" = ?`, uid, unban)
return err
func (db *appdbimpl) UnbanUser(uid string, unban string) (QueryResult, error) {
res, err := db.c.Exec(`DELETE FROM "bans" WHERE "user" = ? AND "ban" = ?`, uid, unban)
if err != nil {
return ERR_INTERNAL, err
}
rows, err := res.RowsAffected()
if err != nil {
return ERR_INTERNAL, err
}
// The user was not banned by this user
if rows == 0 {
return ERR_NOT_FOUND, nil
}
return SUCCESS, nil
}

View file

@ -9,3 +9,17 @@ func EmptySet(err error) bool {
}
return strings.Contains(err.Error(), "no rows in result set")
}
func UniqueViolation(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "UNIQUE constraint failed")
}
func ForeignKeyViolation(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "FOREIGN KEY constraint failed")
}

View file

@ -8,3 +8,15 @@ type UIDName struct {
UID string `json:"user_id"`
Name string `json:"name"`
}
type GenericResponse struct {
Status string `json:"status"`
}
type Comment struct {
CommentID string `json:"comment_id"`
UID string `json:"user_id"`
Name string `json:"name"`
Comment string `json:"comment"`
Date string `json:"date"`
}