Merge pull request #1 from notherealmarco/dev

Dev
This commit is contained in:
Marco Realacci 2023-05-31 13:21:55 +02:00 committed by GitHub
commit 3112eb364b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 10947 additions and 10125 deletions

8
.vscode/launch.json vendored
View file

@ -10,11 +10,11 @@
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"buildFlags": "", "buildFlags": "-tags webui",
"program": "./cmd/webapi", "program": "./cmd/webapi",
"args": [ //"args": [
"--db-filename", "/home/marco/wasa/wasadata/wasaphoto.db", "--data-path", "/home/marco/wasa/wasadata/data" // "--db-filename", "/home/marco/wasa/wasadata/wasaphoto.db", "--data-path", "/home/marco/wasa/wasadata/data"
] //]
} }
] ]
} }

22
Dockerfile.backend Normal file
View file

@ -0,0 +1,22 @@
FROM golang:1.19.1 AS builder
### Copy Go code
WORKDIR /src/
COPY . .
### Build executables
RUN go build -o /app/webapi ./cmd/webapi
### Create final container
FROM debian:bullseye
### Inform Docker about which port is used
EXPOSE 3000 4000
### Copy the build executable from the builder image
WORKDIR /app/
COPY --from=builder /app/webapi ./
### Executable command
CMD ["/app/webapi", "--db-filename", "/data/wasaphoto.db", "--data-path", "/data/data"]

34
Dockerfile.embedded Normal file
View file

@ -0,0 +1,34 @@
FROM node:lts as ui-builder
### Copy Vue.js code
WORKDIR /app
COPY webui webui
### Build Vue.js into plain HTML/CSS/JS
WORKDIR /app/webui
RUN npm i
RUN npm run build-embed
FROM golang:1.19.1 AS builder
### Copy Go code
WORKDIR /src/
COPY . .
COPY --from=ui-builder /app/webui webui
### Build executables
RUN go build -tags webui -o /app/webapi ./cmd/webapi
### Create final container
FROM debian:bullseye
### Inform Docker about which port is used
EXPOSE 3000 4000
### Copy the build executable from the builder image
WORKDIR /app/
COPY --from=builder /app/webapi ./
### Executable command
CMD ["/app/webapi", "--db-filename", "/data/wasaphoto.db", "--data-path", "/data/data"]

16
Dockerfile.frontend Normal file
View file

@ -0,0 +1,16 @@
FROM node:lts as builder
### Copy Vue.js code
WORKDIR /app
COPY webui webui
### Build Vue.js into plain HTML/CSS/JS
WORKDIR /app/webui
RUN npm run build-prod
### Create final container
FROM nginx:stable
### Copy the (built) app from the builder image
COPY --from=builder /app/webui/dist /usr/share/nginx/html

View file

@ -4,7 +4,9 @@
*Keep in touch with your friends by sharing photos of special moments, thanks to WASAPhoto!* *Keep in touch with your friends by sharing photos of special moments, thanks to WASAPhoto!*
*You canupload your photos directly from your PC, and they will be visible to everyone following you.* *You can upload your photos directly from your PC, and they will be visible to everyone following you.*
(Live demo: [https://wasaphoto.marcorealacci.me](https://wasaphoto.marcorealacci.me))
--- ---
@ -17,6 +19,56 @@ This is my project for the Web And Software Architecture (WASA) class
* An API specification using the OpenAPI standard * An API specification using the OpenAPI standard
* A backend written in the Go language * A backend written in the Go language
* A frontend in Vue.js * A frontend in Vue.js
* ~~A Dockerfile to build a Docker image to deploy the project in a container.~~ * Dockerfiles to deploy the backend and the frontend in containers.
* Dockerfile.backend builds the container for the backend
* Dockerfile.frontend builds the container for the frontend
* Dockerfile.embedded builds the backend container, but the backend's webserver also delivers the frontend
*(Strikethrough parts are work in progress or still need to be implemented)* ### Before building
If you're building the project in production mode (see below), you need to specify the base URL for the backend in `vite.config.js`.
## Build & deploy
The only (officially) supported method is via Docker containers.
There are two supported methods.
#### Embedded build
This method is only recommended for testing purposes or instances with very few users (for performance reasons).
The following commands will build a single container to serve both frontend and backend.
```
docker build -t wasaphoto -f Dockerfile.embedded .
docker run -p 3000:3000 -v <path to data directory>:/data --name wasaphoto wasaphoto
```
Everything will be up and running on port 3000 (including the Web UI).
#### Production build
This method build two containers, one for the backend and a container that running nginx to serve the frontend.
This is very recommended on production envinoments.
1. Build and run the backend
```
docker build -t wasabackend -f Dockerfile.backend .
docker run -p 3000:3000 -v <path to data directory>:/data --name wasaphoto-backend wasabackend
```
2. Edit the `vite.config.js` file and replace `<your API URL>` with the backend's base URL.
3. Build and run the frontend
```
docker build -t wasafrontend -f Dockerfile.frontend .
docker run -p 8080:80 --name wasaphoto-frontend wasafrontend
```
The Web UI will be up and running on port 8080!
<your API URL>

View file

@ -81,6 +81,12 @@ func run() error {
logger.Infof("application initializing") logger.Infof("application initializing")
// Create the directories if they don't exist
if err := os.MkdirAll(cfg.Data.Path, 0755); err != nil {
logger.WithError(err).Error("error creating data directory")
return fmt.Errorf("creating data directory: %w", err)
}
// Start Database // Start Database
logger.Println("initializing database support") logger.Println("initializing database support")
dbconn, err := sql.Open("sqlite3", cfg.DB.Filename) dbconn, err := sql.Open("sqlite3", cfg.DB.Filename)
@ -120,6 +126,7 @@ func run() error {
logger.WithError(err).Error("error creating the API server instance") logger.WithError(err).Error("error creating the API server instance")
return fmt.Errorf("creating the API server instance: %w", err) return fmt.Errorf("creating the API server instance: %w", err)
} }
router := apirouter.Handler() router := apirouter.Handler()
router, err = registerWebUI(router) router, err = registerWebUI(router)

View file

@ -4,10 +4,11 @@ package main
import ( import (
"fmt" "fmt"
"github.com/notherealmarco/WASAPhoto/webui"
"io/fs" "io/fs"
"net/http" "net/http"
"strings" "strings"
"github.com/notherealmarco/WASAPhoto/webui"
) )
func registerWebUI(hdl http.Handler) (http.Handler, error) { func registerWebUI(hdl http.Handler) (http.Handler, error) {
@ -20,6 +21,10 @@ func registerWebUI(hdl http.Handler) (http.Handler, error) {
if strings.HasPrefix(r.RequestURI, "/dashboard/") { if strings.HasPrefix(r.RequestURI, "/dashboard/") {
http.StripPrefix("/dashboard/", http.FileServer(http.FS(distDirectory))).ServeHTTP(w, r) http.StripPrefix("/dashboard/", http.FileServer(http.FS(distDirectory))).ServeHTTP(w, r)
return return
} else if r.RequestURI == "/" {
// Redirect to dashboard
http.Redirect(w, r, "/dashboard/", http.StatusTemporaryRedirect)
return
} }
hdl.ServeHTTP(w, r) hdl.ServeHTTP(w, r)
}), nil }), nil

View file

@ -1,4 +1,4 @@
openapi: 3.0.3 openapi: 3.0.2
info: info:
title: WASAPhoto API title: WASAPhoto API
description: |- description: |-
@ -210,7 +210,7 @@ paths:
$ref: "#/components/schemas/generic_response" $ref: "#/components/schemas/generic_response"
example: example:
status: "Resource not found" status: "Resource not found"
'400': # todo: not sure if this is the right error code '400':
description: Trying to follow a user that does not exist. description: Trying to follow a user that does not exist.
content: content:
application/json: application/json:
@ -920,6 +920,7 @@ components:
maxLength: 36 maxLength: 36
description: The user ID. description: The user ID.
format: uuid format: uuid
pattern: '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$' # RFC 4122
example: "1b4e28ba-2fa1-11d2-883f-0016d3cca427" example: "1b4e28ba-2fa1-11d2-883f-0016d3cca427"
name_object: name_object:
type: object type: object
@ -1080,9 +1081,9 @@ components:
comment: comment:
$ref: "#/components/schemas/comment" $ref: "#/components/schemas/comment"
comment: comment:
minLength: 5 minLength: 1
maxLength: 255 maxLength: 255
pattern: ".*" #everything except newlines ^[*]{5, 255}$ pattern: "^(.){1,255}$" # everything except newlines
type: string type: string
example: "What a lovely picture! 😊" example: "What a lovely picture! 😊"
description: The comment's text description: The comment's text
@ -1092,7 +1093,7 @@ components:
format: binary format: binary
minLength: 1 minLength: 1
maxLength: 10485760 # 10 MB maxLength: 10485760 # 10 MB
pattern: "((.|\n)*)" # todo: review. Btw this means "any string" pattern: "((.|\n)*)" # this accepts everything
generic_response: generic_response:
type: object type: object

View file

@ -7,9 +7,11 @@ import (
"github.com/notherealmarco/WASAPhoto/service/database" "github.com/notherealmarco/WASAPhoto/service/database"
) )
// AnonymousAuth is the authentication provider for non logged-in users
type AnonymousAuth struct { type AnonymousAuth struct {
} }
// Returns a newly created AnonymousAuth instance
func BuildAnonymous() *AnonymousAuth { func BuildAnonymous() *AnonymousAuth {
return &AnonymousAuth{} return &AnonymousAuth{}
} }
@ -18,14 +20,17 @@ func (u *AnonymousAuth) GetType() string {
return "Anonymous" return "Anonymous"
} }
// Returns UNAUTHORIZED, as anonymous users are logged in
func (u *AnonymousAuth) Authorized(db database.AppDatabase) (reqcontext.AuthStatus, error) { func (u *AnonymousAuth) Authorized(db database.AppDatabase) (reqcontext.AuthStatus, error) {
return reqcontext.UNAUTHORIZED, nil return reqcontext.UNAUTHORIZED, nil
} }
// Returns UNAUTHORIZED, as anonymous users are not logged in
func (u *AnonymousAuth) UserAuthorized(db database.AppDatabase, uid string) (reqcontext.AuthStatus, error) { func (u *AnonymousAuth) UserAuthorized(db database.AppDatabase, uid string) (reqcontext.AuthStatus, error) {
return reqcontext.UNAUTHORIZED, nil return reqcontext.UNAUTHORIZED, nil
} }
// Returns an empty string, as anonymous users have no user ID
func (u *AnonymousAuth) GetUserID() string { func (u *AnonymousAuth) GetUserID() string {
return "" return ""
} }

View file

@ -8,6 +8,8 @@ import (
"github.com/notherealmarco/WASAPhoto/service/database" "github.com/notherealmarco/WASAPhoto/service/database"
) )
// BearerAuth is the authentication provider that authorizes users by Bearer tokens
// In this case, a token is the unique identifier for a user.
type BearerAuth struct { type BearerAuth struct {
token string token string
} }
@ -16,6 +18,8 @@ func (b *BearerAuth) GetType() string {
return "Bearer" return "Bearer"
} }
// Given the content of the Authorization header, returns a BearerAuth instance for the user
// Returns an error if the header is not valid
func BuildBearer(header string) (*BearerAuth, error) { func BuildBearer(header string) (*BearerAuth, error) {
if header == "" { if header == "" {
return nil, errors.New("missing authorization header") return nil, errors.New("missing authorization header")
@ -29,10 +33,12 @@ func BuildBearer(header string) (*BearerAuth, error) {
return &BearerAuth{token: header[7:]}, nil return &BearerAuth{token: header[7:]}, nil
} }
// Returns the user ID of the user that is currently logged in
func (b *BearerAuth) GetUserID() string { func (b *BearerAuth) GetUserID() string {
return b.token return b.token
} }
// Checks if the token is valid
func (b *BearerAuth) Authorized(db database.AppDatabase) (reqcontext.AuthStatus, error) { func (b *BearerAuth) Authorized(db database.AppDatabase) (reqcontext.AuthStatus, error) {
// this is the way we manage authorization, the bearer token is the user id // this is the way we manage authorization, the bearer token is the user id
state, err := db.UserExists(b.token) state, err := db.UserExists(b.token)
@ -47,6 +53,7 @@ func (b *BearerAuth) Authorized(db database.AppDatabase) (reqcontext.AuthStatus,
return reqcontext.UNAUTHORIZED, nil return reqcontext.UNAUTHORIZED, nil
} }
// Checks if the given user and the currently logged in user are the same user
func (b *BearerAuth) UserAuthorized(db database.AppDatabase, uid string) (reqcontext.AuthStatus, error) { func (b *BearerAuth) UserAuthorized(db database.AppDatabase, uid string) (reqcontext.AuthStatus, error) {
// If uid is not a valid user, return USER_NOT_FOUND // If uid is not a valid user, return USER_NOT_FOUND
@ -60,6 +67,7 @@ func (b *BearerAuth) UserAuthorized(db database.AppDatabase, uid string) (reqcon
} }
if b.token == uid { if b.token == uid {
// If the user is the same as the one in the token, check if the user does actually exist in the database
auth, err := b.Authorized(db) auth, err := b.Authorized(db)
if err != nil { if err != nil {
@ -68,5 +76,6 @@ func (b *BearerAuth) UserAuthorized(db database.AppDatabase, uid string) (reqcon
return auth, nil return auth, nil
} }
// If the user is not the same as the one in the token, return FORBIDDEN
return reqcontext.FORBIDDEN, nil return reqcontext.FORBIDDEN, nil
} }

View file

@ -10,6 +10,7 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
// BuildAuth returns an Authorization implementation for the currently logged in user
func BuildAuth(header string) (reqcontext.Authorization, error) { func BuildAuth(header string) (reqcontext.Authorization, error) {
auth, err := BuildBearer(header) auth, err := BuildBearer(header)
if err != nil { if err != nil {
@ -21,6 +22,8 @@ func BuildAuth(header string) (reqcontext.Authorization, error) {
return auth, nil return auth, nil
} }
// Given a user authorization function, if the function returns some error, it sends the error to the client and return false
// Otherwise it returns true without sending anything to the client
func SendAuthorizationError(f func(db database.AppDatabase, uid string) (reqcontext.AuthStatus, error), uid string, db database.AppDatabase, w http.ResponseWriter, l logrus.FieldLogger, notFoundStatus int) bool { func SendAuthorizationError(f func(db database.AppDatabase, uid string) (reqcontext.AuthStatus, error), uid string, db database.AppDatabase, w http.ResponseWriter, l logrus.FieldLogger, notFoundStatus int) bool {
auth, err := f(db, uid) auth, err := f(db, uid)
if err != nil { if err != nil {
@ -28,21 +31,25 @@ func SendAuthorizationError(f func(db database.AppDatabase, uid string) (reqcont
return false return false
} }
if auth == reqcontext.UNAUTHORIZED { if auth == reqcontext.UNAUTHORIZED {
// The token is not valid
helpers.SendStatus(http.StatusUnauthorized, w, "Unauthorized", l) helpers.SendStatus(http.StatusUnauthorized, w, "Unauthorized", l)
return false return false
} }
if auth == reqcontext.FORBIDDEN { if auth == reqcontext.FORBIDDEN {
// The user is not authorized for this action
helpers.SendStatus(http.StatusForbidden, w, "Forbidden", l) helpers.SendStatus(http.StatusForbidden, w, "Forbidden", l)
return false return false
} }
// requested user is not found -> 404 as the resource is not found
if auth == reqcontext.USER_NOT_FOUND { if auth == reqcontext.USER_NOT_FOUND {
// Attempting to perform an action on a non-existent user
helpers.SendStatus(notFoundStatus, w, "User not found", l) helpers.SendStatus(notFoundStatus, w, "User not found", l)
return false return false
} }
return true return true
} }
// Given a function that validates a token, if the function returns some error, it sends the error to the client and return false
// Otherwise it returns true without sending anything to the client
func SendErrorIfNotLoggedIn(f func(db database.AppDatabase) (reqcontext.AuthStatus, error), db database.AppDatabase, w http.ResponseWriter, l logrus.FieldLogger) bool { func SendErrorIfNotLoggedIn(f func(db database.AppDatabase) (reqcontext.AuthStatus, error), db database.AppDatabase, w http.ResponseWriter, l logrus.FieldLogger) bool {
auth, err := f(db) auth, err := f(db)
@ -53,6 +60,7 @@ func SendErrorIfNotLoggedIn(f func(db database.AppDatabase) (reqcontext.AuthStat
} }
if auth == reqcontext.UNAUTHORIZED { if auth == reqcontext.UNAUTHORIZED {
// The token is not valid
helpers.SendStatus(http.StatusUnauthorized, w, "Unauthorized", l) helpers.SendStatus(http.StatusUnauthorized, w, "Unauthorized", l)
return false return false
} }

View file

@ -66,6 +66,7 @@ func (rt *_router) PutBan(w http.ResponseWriter, r *http.Request, ps httprouter.
return return
} }
// Execute the query
status, err := rt.db.BanUser(uid, banned) status, err := rt.db.BanUser(uid, banned)
if err != nil { if err != nil {
@ -83,6 +84,13 @@ func (rt *_router) PutBan(w http.ResponseWriter, r *http.Request, ps httprouter.
return return
} }
// Removes the banning user to the banned user's followers (if present)
_, err = rt.db.UnfollowUser(banned, uid)
if err != nil {
helpers.SendInternalError(err, "Database error: UnfollowUser", w, rt.baseLogger)
}
helpers.SendStatus(http.StatusCreated, w, "Success", rt.baseLogger) helpers.SendStatus(http.StatusCreated, w, "Success", rt.baseLogger)
} }
@ -95,6 +103,7 @@ func (rt *_router) DeleteBan(w http.ResponseWriter, r *http.Request, ps httprout
return return
} }
// Execute the query
status, err := rt.db.UnbanUser(uid, banned) status, err := rt.db.UnbanUser(uid, banned)
if err != nil { if err != nil {

View file

@ -3,7 +3,6 @@ package api
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"regexp"
"strconv" "strconv"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
@ -56,6 +55,7 @@ func (rt *_router) GetComments(w http.ResponseWriter, r *http.Request, ps httpro
} }
// send the response // send the response
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(comments) err = json.NewEncoder(w).Encode(comments)
if err != nil { if err != nil {
@ -89,15 +89,7 @@ func (rt *_router) PostComment(w http.ResponseWriter, r *http.Request, ps httpro
} }
// check if the comment is valid (should not contain newlines and at be between 5 and 255 characters) // check if the comment is valid (should not contain newlines and at be between 5 and 255 characters)
stat, err := regexp.Match(`^[*]{5, 255}$`, []byte(request_body.Comment)) if !helpers.MatchCommentOrBadRequest(request_body.Comment, w, rt.baseLogger) {
if err != nil {
helpers.SendInternalError(err, "Error matching regex", w, rt.baseLogger)
return
}
if !stat {
helpers.SendBadRequest(w, "Invalid comment", rt.baseLogger)
return return
} }

View file

@ -79,6 +79,7 @@ func (rt *_router) PutFollow(w http.ResponseWriter, r *http.Request, ps httprout
return return
} }
// Execute the query
status, err := rt.db.FollowUser(follower, uid) status, err := rt.db.FollowUser(follower, uid)
if err != nil { if err != nil {
@ -109,6 +110,7 @@ func (rt *_router) DeleteFollow(w http.ResponseWriter, r *http.Request, ps httpr
return return
} }
// Execute the query
status, err := rt.db.UnfollowUser(follower, uid) status, err := rt.db.UnfollowUser(follower, uid)
if err != nil { if err != nil {

View file

@ -10,6 +10,8 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
// Tries to decode a json, if it fails, it returns Bad Request to the client and the function returns false
// Otherwise it returns true without sending anything to the client
func DecodeJsonOrBadRequest(r io.Reader, w http.ResponseWriter, v interface{}, l logrus.FieldLogger) bool { func DecodeJsonOrBadRequest(r io.Reader, w http.ResponseWriter, v interface{}, l logrus.FieldLogger) bool {
err := json.NewDecoder(r).Decode(v) err := json.NewDecoder(r).Decode(v)
@ -20,6 +22,8 @@ func DecodeJsonOrBadRequest(r io.Reader, w http.ResponseWriter, v interface{}, l
return true return true
} }
// Verifies if a user exists, if it doesn't, it returns Not Found to the client and the function returns false
// Otherwise it returns true without sending anything to the client
func VerifyUserOrNotFound(db database.AppDatabase, uid string, w http.ResponseWriter, l logrus.FieldLogger) bool { func VerifyUserOrNotFound(db database.AppDatabase, uid string, w http.ResponseWriter, l logrus.FieldLogger) bool {
user_exists, err := db.UserExists(uid) user_exists, err := db.UserExists(uid)
@ -36,6 +40,8 @@ func VerifyUserOrNotFound(db database.AppDatabase, uid string, w http.ResponseWr
return true return true
} }
// Sends a generic status response
// The response is a json object with a "status" field desribing the status of a request
func SendStatus(httpStatus int, w http.ResponseWriter, description string, l logrus.FieldLogger) { func SendStatus(httpStatus int, w http.ResponseWriter, description string, l logrus.FieldLogger) {
w.WriteHeader(httpStatus) w.WriteHeader(httpStatus)
err := json.NewEncoder(w).Encode(structures.GenericResponse{Status: description}) err := json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
@ -44,40 +50,29 @@ func SendStatus(httpStatus int, w http.ResponseWriter, description string, l log
} }
} }
// Sends a Not Found error to the client
func SendNotFound(w http.ResponseWriter, description string, l logrus.FieldLogger) { func SendNotFound(w http.ResponseWriter, description string, l logrus.FieldLogger) {
w.WriteHeader(http.StatusNotFound) SendStatus(http.StatusNotFound, w, description, l)
err := json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
if err != nil {
l.WithError(err).Error("Error encoding json")
}
} }
// Sends a Bad Request error to the client
func SendBadRequest(w http.ResponseWriter, description string, l logrus.FieldLogger) { func SendBadRequest(w http.ResponseWriter, description string, l logrus.FieldLogger) {
w.WriteHeader(http.StatusBadRequest) SendStatus(http.StatusBadRequest, w, description, l)
err := json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
if err != nil {
l.WithError(err).Error("Error encoding json")
}
} }
// Sends a Bad Request error to the client and logs the given error
func SendBadRequestError(err error, description string, w http.ResponseWriter, l logrus.FieldLogger) { func SendBadRequestError(err error, description string, w http.ResponseWriter, l logrus.FieldLogger) {
w.WriteHeader(http.StatusBadRequest)
l.WithError(err).Error(description) l.WithError(err).Error(description)
err = json.NewEncoder(w).Encode(structures.GenericResponse{Status: description}) SendBadRequest(w, description, l)
if err != nil {
l.WithError(err).Error("Error encoding json")
}
} }
// Sends an Internal Server Error to the client and logs the given error
func SendInternalError(err error, description string, w http.ResponseWriter, l logrus.FieldLogger) { func SendInternalError(err error, description string, w http.ResponseWriter, l logrus.FieldLogger) {
w.WriteHeader(http.StatusInternalServerError)
l.WithError(err).Error(description) l.WithError(err).Error(description)
err = json.NewEncoder(w).Encode(structures.GenericResponse{Status: description}) SendStatus(http.StatusInternalServerError, w, description, l)
if err != nil {
l.WithError(err).Error("Error encoding json")
}
} }
// Tries to roll back a transaction, if it fails it logs the error
func RollbackOrLogError(tx database.DBTransaction, l logrus.FieldLogger) { func RollbackOrLogError(tx database.DBTransaction, l logrus.FieldLogger) {
err := tx.Rollback() err := tx.Rollback()
if err != nil { if err != nil {
@ -85,6 +80,8 @@ func RollbackOrLogError(tx database.DBTransaction, l logrus.FieldLogger) {
} }
} }
// Checks if a user is banned by another user, then it returns Not Found to the client and the function returns false
// Otherwise it returns true whithout sending anything to the client
func SendNotFoundIfBanned(db database.AppDatabase, uid string, banner string, w http.ResponseWriter, l logrus.FieldLogger) bool { func SendNotFoundIfBanned(db database.AppDatabase, uid string, banner string, w http.ResponseWriter, l logrus.FieldLogger) bool {
banned, err := db.IsBanned(uid, banner) banned, err := db.IsBanned(uid, banner)
if err != nil { if err != nil {

View file

@ -6,10 +6,12 @@ import (
) )
const ( const (
DEFAULT_LIMIT = 15 // don't know if should be moved to config DEFAULT_LIMIT = 30
DEFAULT_OFFSET = 0 DEFAULT_OFFSET = 0
) )
// Get the start index and limit from the query.
// If they are not present, use the default values.
func GetLimits(query url.Values) (int, int, error) { func GetLimits(query url.Values) (int, int, error) {
limit := DEFAULT_LIMIT limit := DEFAULT_LIMIT

View file

@ -0,0 +1,44 @@
package helpers
import (
"net/http"
"regexp"
"github.com/sirupsen/logrus"
)
// Given a string, a regex and an error description, if the string doesn't match the regex, it sends a bad request error to the client and return false
// Otherwise it returns true without sending anything to the client
func MatchRegexOrBadRequest(str string, regex string, error_description string, w http.ResponseWriter, l logrus.FieldLogger) bool {
stat, err := regexp.Match(regex, []byte(str))
if err != nil {
SendInternalError(err, "Error while matching username regex", w, l)
return false
}
if !stat {
// string didn't match the regex, so it's invalid, let's send a bad request error
SendBadRequest(w, error_description, l)
return false
}
// string matched the regex, so it's valid
return true
}
// Validates a username (must be between 3 and 16 characters long and can only contain letters, numbers and underscores)
func MatchUsernameOrBadRequest(username string, w http.ResponseWriter, l logrus.FieldLogger) bool {
return MatchRegexOrBadRequest(username,
`^[a-zA-Z0-9_]{3,16}$`, "Username must be between 3 and 16 characters long and can only contain letters, numbers and underscores",
w,
l)
}
// Validates a comment (must be between 1 and 255 characters long)
func MatchCommentOrBadRequest(comment string, w http.ResponseWriter, l logrus.FieldLogger) bool {
return MatchRegexOrBadRequest(comment,
`^(.){1,255}$`, "Comment must be between 1 and 255 characters long",
w,
l)
}

View file

@ -1,16 +1,18 @@
package api package api
import ( import (
"github.com/julienschmidt/httprouter"
"net/http" "net/http"
"github.com/julienschmidt/httprouter"
"github.com/notherealmarco/WASAPhoto/service/api/helpers"
) )
// liveness is an HTTP handler that checks the API server status. If the server cannot serve requests (e.g., some // liveness is an HTTP handler that checks the API server status. If the server cannot serve requests (e.g., some
// resources are not ready), this should reply with HTTP Status 500. Otherwise, with HTTP Status 200 // resources are not ready), this should reply with HTTP Status 500. Otherwise, with HTTP Status 200
func (rt *_router) liveness(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { func (rt *_router) liveness(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
/* Example of liveness check: if err := rt.db.Ping(); err != nil {
if err := rt.DB.Ping(); err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return return
}*/ }
helpers.SendStatus(200, w, "Server is live!", rt.baseLogger)
} }

View file

@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"github.com/notherealmarco/WASAPhoto/service/api/authorization" "github.com/notherealmarco/WASAPhoto/service/api/authorization"
@ -32,36 +33,50 @@ func (rt *_router) PostPhoto(w http.ResponseWriter, r *http.Request, ps httprout
} }
path := rt.dataPath + "/photos/" + uid + "/" + strconv.FormatInt(photo_id, 10) + ".jpg" 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 if err = os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { // perms = 511
helpers.SendInternalError(err, "Error creating directory", w, rt.baseLogger) helpers.SendInternalError(err, "Error creating directory", w, rt.baseLogger)
return return
} }
file, err := os.Create(path) /*file, err := os.Create(path)
if err != nil { if err != nil {
helpers.SendInternalError(err, "Error creating file", w, rt.baseLogger) helpers.SendInternalError(err, "Error creating file", w, rt.baseLogger)
helpers.RollbackOrLogError(transaction, rt.baseLogger) helpers.RollbackOrLogError(transaction, rt.baseLogger)
return return
}*/
bytes, err := io.ReadAll(r.Body)
if err != nil {
helpers.SendInternalError(err, "Error checking the file", w, rt.baseLogger)
helpers.RollbackOrLogError(transaction, rt.baseLogger)
return
} }
if _, err = io.Copy(file, r.Body); err != nil { mimeType := http.DetectContentType(bytes)
if !strings.HasPrefix(mimeType, "image/") {
helpers.SendStatus(http.StatusBadRequest, w, mimeType+" file is not a valid image", rt.baseLogger)
helpers.RollbackOrLogError(transaction, rt.baseLogger)
return
}
if err = os.WriteFile(path, bytes, 0644); err != nil {
helpers.SendInternalError(err, "Error writing the file", w, rt.baseLogger) helpers.SendInternalError(err, "Error writing the file", w, rt.baseLogger)
helpers.RollbackOrLogError(transaction, rt.baseLogger) helpers.RollbackOrLogError(transaction, rt.baseLogger)
return return
} }
if err = file.Close(); err != nil { /*if err = file.Close(); err != nil {
helpers.SendInternalError(err, "Error closing file", w, rt.baseLogger) helpers.SendInternalError(err, "Error closing file", w, rt.baseLogger)
helpers.RollbackOrLogError(transaction, rt.baseLogger) helpers.RollbackOrLogError(transaction, rt.baseLogger)
} }*/
err = transaction.Commit() err = transaction.Commit()
if err != nil { if err != nil {
helpers.SendInternalError(err, "Error committing transaction", w, rt.baseLogger) helpers.SendInternalError(err, "Error committing transaction", w, rt.baseLogger)
//todo: should I roll back?
return return
} }
@ -139,7 +154,7 @@ func (rt *_router) DeletePhoto(w http.ResponseWriter, r *http.Request, ps httpro
if err != nil { if err != nil {
helpers.SendInternalError(err, "Error deleting photo from database", w, rt.baseLogger) helpers.SendInternalError(err, "Error deleting photo from database", w, rt.baseLogger)
return return
} // todo: maybe let's use a transaction also here }
if !deleted { if !deleted {
helpers.SendNotFound(w, "Photo not found", rt.baseLogger) helpers.SendNotFound(w, "Photo not found", rt.baseLogger)

View file

@ -25,19 +25,36 @@ func (rt *_router) PostSession(w http.ResponseWriter, r *http.Request, ps httpro
var request _reqbody var request _reqbody
err := json.NewDecoder(r.Body).Decode(&request) err := json.NewDecoder(r.Body).Decode(&request)
var uid string if err != nil {
if err == nil { // test if user exists
uid, err = rt.db.GetUserID(request.Name)
}
if db_errors.EmptySet(err) { // user does not exist
uid, err = rt.db.CreateUser(request.Name)
}
if err != nil { // handle any other error
helpers.SendBadRequestError(err, "Bad request body", w, rt.baseLogger) helpers.SendBadRequestError(err, "Bad request body", w, rt.baseLogger)
return return
} }
// test if user exists
var uid string
uid, err = rt.db.GetUserID(request.Name)
// check if the database returned an empty set error, if so, create the new user
if db_errors.EmptySet(err) {
// before creating the user, check if the name is valid, otherwise send a bad request error
if !helpers.MatchUsernameOrBadRequest(request.Name, w, rt.baseLogger) {
return
}
uid, err = rt.db.CreateUser(request.Name)
}
// handle database errors
if err != nil {
helpers.SendInternalError(err, "Database error", w, rt.baseLogger)
return
}
// set the response header
w.Header().Set("content-type", "application/json") w.Header().Set("content-type", "application/json")
// encode the response body
err = json.NewEncoder(w).Encode(_respbody{UID: uid}) err = json.NewEncoder(w).Encode(_respbody{UID: uid})
if err != nil { if err != nil {

View file

@ -2,7 +2,6 @@ package api
import ( import (
"net/http" "net/http"
"regexp"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"github.com/notherealmarco/WASAPhoto/service/api/authorization" "github.com/notherealmarco/WASAPhoto/service/api/authorization"
@ -15,33 +14,32 @@ import (
func (rt *_router) UpdateUsername(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) { func (rt *_router) UpdateUsername(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
uid := ps.ByName("user_id") uid := ps.ByName("user_id")
// check if the user is changing his own username
if !authorization.SendAuthorizationError(ctx.Auth.UserAuthorized, uid, rt.db, w, rt.baseLogger, http.StatusNotFound) { if !authorization.SendAuthorizationError(ctx.Auth.UserAuthorized, uid, rt.db, w, rt.baseLogger, http.StatusNotFound) {
return return
} }
// decode request body
var req structures.UserDetails var req structures.UserDetails
if !helpers.DecodeJsonOrBadRequest(r.Body, w, &req, rt.baseLogger) { if !helpers.DecodeJsonOrBadRequest(r.Body, w, &req, rt.baseLogger) {
return return
} }
stat, err := regexp.Match(`^[a-zA-Z0-9_]{3,16}$`, []byte(req.Name)) // check if the username is valid, and if it's not, send a bad request error
if !helpers.MatchUsernameOrBadRequest(req.Name, w, rt.baseLogger) {
if err != nil {
helpers.SendInternalError(err, "Error while matching username", w, rt.baseLogger)
return
}
if !stat { //todo: sta regex non me piace
helpers.SendBadRequest(w, "Username must be between 3 and 16 characters long and can only contain letters, numbers and underscores", rt.baseLogger)
return return
} }
status, err := rt.db.UpdateUsername(uid, req.Name) status, err := rt.db.UpdateUsername(uid, req.Name)
// check if the username already exists
if status == database.ERR_EXISTS { if status == database.ERR_EXISTS {
helpers.SendStatus(http.StatusConflict, w, "Username already exists", rt.baseLogger) helpers.SendStatus(http.StatusConflict, w, "Username already exists", rt.baseLogger)
return return
} }
// handle any other database error
if err != nil { if err != nil {
helpers.SendInternalError(err, "Database error: UpdateUsername", w, rt.baseLogger) helpers.SendInternalError(err, "Database error: UpdateUsername", w, rt.baseLogger)
return return

View file

@ -11,9 +11,17 @@ const (
USER_NOT_FOUND = 3 USER_NOT_FOUND = 3
) )
// Authorization is the interface for an authorization provider
type Authorization interface { type Authorization interface {
// Returns the type of the authorization provider
GetType() string GetType() string
// Returns the ID of the currently logged in user
GetUserID() string GetUserID() string
// Checks if the token is valid
Authorized(db database.AppDatabase) (AuthStatus, error) Authorized(db database.AppDatabase) (AuthStatus, error)
// Checks if the given user and the currently logged in user are the same user
UserAuthorized(db database.AppDatabase, uid string) (AuthStatus, error) UserAuthorized(db database.AppDatabase, uid string) (AuthStatus, error)
} }

View file

@ -79,8 +79,11 @@ type AppDatabase interface {
Ping() error Ping() error
} }
// DBTransaction is the interface for a generic database transaction
type DBTransaction interface { type DBTransaction interface {
// Commit commits the transaction
Commit() error Commit() error
// Rollback rolls back the transaction
Rollback() error Rollback() error
} }
@ -95,16 +98,17 @@ func New(db *sql.DB) (AppDatabase, error) {
return nil, errors.New("database is required when building a AppDatabase") return nil, errors.New("database is required when building a AppDatabase")
} }
// Check if tables exist. If not, the database is empty, and we need to create the structure // Check if some table exists. If not, the database is empty, and we need to create the structure
var tableName string var tableName string
//todo: check for all the tables, not just users
err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='users';`).Scan(&tableName) err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='users';`).Scan(&tableName)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
// Database is empty, let's create the structure
sqlStmt := `CREATE TABLE "users" ( sqlStmt := `CREATE TABLE "users" (
"uid" TEXT NOT NULL, "uid" TEXT NOT NULL,
"name" TEXT NOT NULL UNIQUE, "name" TEXT NOT NULL UNIQUE,
PRIMARY KEY("uid") PRIMARY KEY("uid")
)` // todo: one query is enough! We are we doing a query per table? )`
_, err = db.Exec(sqlStmt) _, err = db.Exec(sqlStmt)
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating database structure: %w", err) return nil, fmt.Errorf("error creating database structure: %w", err)

View file

@ -103,6 +103,7 @@ func (db *appdbimpl) GetComments(uid string, photo_id int64, requesting_uid stri
AND "bans"."ban" = ? AND "bans"."ban" = ?
) )
AND "u"."uid" = "c"."user" AND "u"."uid" = "c"."user"
ORDER BY "c"."date" DESC
LIMIT ? LIMIT ?
OFFSET ?`, photo_id, requesting_uid, limit, start_index) OFFSET ?`, photo_id, requesting_uid, limit, start_index)

View file

@ -96,6 +96,7 @@ func (db *appdbimpl) UnlikePhoto(uid string, photo int64, liker_uid string) (Que
// But our DB implementation only requires the photo id. // But our DB implementation only requires the photo id.
exists, err := db.photoExists(uid, photo) exists, err := db.photoExists(uid, photo)
if err != nil || !exists { if err != nil || !exists {
// The photo does not exist, or the user has been banned
return ERR_NOT_FOUND, err return ERR_NOT_FOUND, err
} }
@ -111,6 +112,7 @@ func (db *appdbimpl) UnlikePhoto(uid string, photo int64, liker_uid string) (Que
return ERR_INTERNAL, err return ERR_INTERNAL, err
} }
// The user was not liking the photo
if rows == 0 { if rows == 0 {
return ERR_NOT_FOUND, nil return ERR_NOT_FOUND, nil
} }

View file

@ -18,7 +18,6 @@ func (db *appdbimpl) PostPhoto(uid string) (DBTransaction, int64, error) {
err_rb := tx.Rollback() err_rb := tx.Rollback()
// If rollback fails, we return the original error plus the rollback error // If rollback fails, we return the original error plus the rollback error
if err_rb != nil { if err_rb != nil {
// todo: we are losing track of err_rb here
err = fmt.Errorf("Rollback error. Rollback cause: %w", err) err = fmt.Errorf("Rollback error. Rollback cause: %w", err)
} }
@ -30,7 +29,6 @@ func (db *appdbimpl) PostPhoto(uid string) (DBTransaction, int64, error) {
err_rb := tx.Rollback() err_rb := tx.Rollback()
// If rollback fails, we return the original error plus the rollback error // If rollback fails, we return the original error plus the rollback error
if err_rb != nil { if err_rb != nil {
// todo: we are losing track of err_rb here
err = fmt.Errorf("Rollback error. Rollback cause: %w", err) err = fmt.Errorf("Rollback error. Rollback cause: %w", err)
} }
@ -66,6 +64,7 @@ func (db *appdbimpl) photoExists(uid string, photo int64) (bool, error) {
return cnt > 0, nil return cnt > 0, nil
} }
// Check if a given photo owned by a given user exists, and the requesting user is not banned by the author
func (db *appdbimpl) PhotoExists(uid string, photo int64, requesting_uid string) (bool, error) { func (db *appdbimpl) PhotoExists(uid string, photo int64, requesting_uid string) (bool, error) {
var cnt int64 var cnt int64

View file

@ -5,8 +5,6 @@ import (
"github.com/notherealmarco/WASAPhoto/service/structures" "github.com/notherealmarco/WASAPhoto/service/structures"
) )
//this should be changed, but we need to change OpenAPI first
// Get user profile, including username, followers, following, and photos // Get user profile, including username, followers, following, and photos
func (db *appdbimpl) GetUserProfile(uid string, requesting_uid string) (QueryResult, *structures.UserProfile, error) { func (db *appdbimpl) GetUserProfile(uid string, requesting_uid string) (QueryResult, *structures.UserProfile, error) {
// Get user info // Get user info

View file

@ -2,14 +2,17 @@ package database
import "database/sql" import "database/sql"
// dbtransaction is a struct to represent an SQL transaction, it implements the DBTransaction interface
type dbtransaction struct { type dbtransaction struct {
c *sql.Tx c *sql.Tx
} }
func (tx *dbtransaction) Commit() error { func (tx *dbtransaction) Commit() error {
// Commit the SQL transaction
return tx.c.Commit() return tx.c.Commit()
} }
func (tx *dbtransaction) Rollback() error { func (tx *dbtransaction) Rollback() error {
// Rollback the SQL transaction
return tx.c.Rollback() return tx.c.Rollback()
} }

View file

@ -2,6 +2,7 @@ package database
import ( import (
"database/sql" "database/sql"
"errors"
"github.com/gofrs/uuid" "github.com/gofrs/uuid"
"github.com/notherealmarco/WASAPhoto/service/database/db_errors" "github.com/notherealmarco/WASAPhoto/service/database/db_errors"
@ -41,28 +42,57 @@ func (db *appdbimpl) UserExistsNotBanned(uid string, requesting_uid string) (boo
// Get user id by username // Get user id by username
func (db *appdbimpl) GetUserID(name string) (string, error) { func (db *appdbimpl) GetUserID(name string) (string, error) {
var uid string var uid string
err := db.c.QueryRow(`SELECT "uid" FROM "users" WHERE "name" = ?`, name).Scan(&uid) err := db.c.QueryRow(`SELECT "uid" FROM "users" WHERE "name" LIKE ?`, name).Scan(&uid)
return uid, err return uid, err
} }
// Create a new user // Create a new user
func (db *appdbimpl) CreateUser(name string) (string, error) { func (db *appdbimpl) CreateUser(name string) (string, error) {
// check if username is taken (case insensitive)
exists, err := db.nameExists(name)
if err != nil {
return "", err
} else if exists {
return "", errors.New("username already exists")
}
// create new user id
uid, err := uuid.NewV4() uid, err := uuid.NewV4()
if err != nil { if err != nil {
return "", err return "", err
} }
// insert the new user into the database
_, err = db.c.Exec(`INSERT INTO "users" ("uid", "name") VALUES (?, ?)`, uid.String(), name) _, err = db.c.Exec(`INSERT INTO "users" ("uid", "name") VALUES (?, ?)`, uid.String(), name)
return uid.String(), err return uid.String(), err
} }
// Check if username exists
func (db *appdbimpl) nameExists(name string) (bool, error) {
var cnt int
err := db.c.QueryRow(`SELECT COUNT(*) FROM "users" WHERE "name" LIKE ?`, name).Scan(&cnt)
if err != nil {
return false, err
}
return cnt > 0, nil
}
// Update username // Update username
func (db *appdbimpl) UpdateUsername(uid string, name string) (QueryResult, error) { func (db *appdbimpl) UpdateUsername(uid string, name string) (QueryResult, error) {
_, err := db.c.Exec(`UPDATE "users" SET "name" = ? WHERE "uid" = ?`, name, uid)
if db_errors.UniqueViolation(err) { // check if username is taken (case insensitive)
exists, err := db.nameExists(name)
if err != nil {
return ERR_INTERNAL, err
} else if exists {
return ERR_EXISTS, nil return ERR_EXISTS, nil
} }
_, err = db.c.Exec(`UPDATE "users" SET "name" = ? WHERE "uid" = ?`, name, uid)
if err != nil { if err != nil {
return ERR_INTERNAL, err return ERR_INTERNAL, err
} }
@ -95,7 +125,7 @@ func (db *appdbimpl) GetUserFollowers(uid string, requesting_uid string, start_i
AND "followed" = ? AND "followed" = ?
LIMIT ? LIMIT ?
OFFSET ?`, uid, requesting_uid, limit, start_index) OFFSET ?`, requesting_uid, uid, limit, start_index)
followers, err := db.uidNameQuery(rows, err) followers, err := db.uidNameQuery(rows, err)
@ -107,7 +137,7 @@ func (db *appdbimpl) GetUserFollowers(uid string, requesting_uid string, start_i
} }
// Get user following // Get user following
func (db *appdbimpl) GetUserFollowing(uid string, requesting_uid string, start_index int, offset int) (QueryResult, *[]structures.UIDName, error) { func (db *appdbimpl) GetUserFollowing(uid string, requesting_uid string, start_index int, limit int) (QueryResult, *[]structures.UIDName, error) {
// user may exist but have no followers // user may exist but have no followers
exists, err := db.UserExistsNotBanned(uid, requesting_uid) exists, err := db.UserExistsNotBanned(uid, requesting_uid)
@ -120,7 +150,7 @@ func (db *appdbimpl) GetUserFollowing(uid string, requesting_uid string, start_i
return ERR_NOT_FOUND, nil, nil return ERR_NOT_FOUND, nil, nil
} }
rows, err := db.c.Query(`SELECT "followed", "user"."name" FROM "follows", "users" rows, err := db.c.Query(`SELECT "followed", "users"."name" FROM "follows", "users"
WHERE "follows"."followed" = "users"."uid" WHERE "follows"."followed" = "users"."uid"
AND "follows"."followed" NOT IN ( AND "follows"."followed" NOT IN (
@ -131,7 +161,7 @@ func (db *appdbimpl) GetUserFollowing(uid string, requesting_uid string, start_i
AND "follower" = ? AND "follower" = ?
LIMIT ? LIMIT ?
OFFSET ?`, uid, requesting_uid, offset, start_index) OFFSET ?`, requesting_uid, uid, limit, start_index)
following, err := db.uidNameQuery(rows, err) following, err := db.uidNameQuery(rows, err)

View file

@ -2,7 +2,7 @@ package db_errors
import "strings" import "strings"
// Returns true if the query result has no rows // Returns true if the error is a "no rows in result set" error
func EmptySet(err error) bool { func EmptySet(err error) bool {
if err == nil { if err == nil {
return false return false
@ -10,6 +10,7 @@ func EmptySet(err error) bool {
return strings.Contains(err.Error(), "no rows in result set") return strings.Contains(err.Error(), "no rows in result set")
} }
// Returns true if the error is a Unique constraint violation error
func UniqueViolation(err error) bool { func UniqueViolation(err error) bool {
if err == nil { if err == nil {
return false return false
@ -17,6 +18,7 @@ func UniqueViolation(err error) bool {
return strings.Contains(err.Error(), "UNIQUE constraint failed") return strings.Contains(err.Error(), "UNIQUE constraint failed")
} }
// Returns true if the error is a Foreign Key constraint violation error
func ForeignKeyViolation(err error) bool { func ForeignKeyViolation(err error) bool {
if err == nil { if err == nil {
return false return false

View file

@ -1,7 +0,0 @@
package database
// SetName is an example that shows you how to execute insert/update
func (db *appdbimpl) SetName(name string) error {
_, err := db.c.Exec("INSERT INTO example_table (id, name) VALUES (1, ?)", name)
return err
}

Binary file not shown.

BIN
webapi

Binary file not shown.

View file

@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>Example app</title> <title>WASAPhoto</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"/> <link rel="icon" href="/favicon.ico"/>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">

View file

@ -159,83 +159,83 @@ var pify$1 = pify$2.exports = function (obj, P, opts) {
pify$1.all = pify$1; pify$1.all = pify$1;
var fs = require$$0__default; var fs = require$$0__default;
var path$2 = require$$0; var path$2 = require$$0;
var pify = pify$2.exports; var pify = pify$2.exports;
var stat = pify(fs.stat); var stat = pify(fs.stat);
var readFile = pify(fs.readFile); var readFile = pify(fs.readFile);
var resolve = path$2.resolve; var resolve = path$2.resolve;
var cache = Object.create(null); var cache = Object.create(null);
function convert(content, encoding) { function convert(content, encoding) {
if (Buffer.isEncoding(encoding)) { if (Buffer.isEncoding(encoding)) {
return content.toString(encoding); return content.toString(encoding);
} }
return content; return content;
} }
readCache$1.exports = function (path, encoding) { readCache$1.exports = function (path, encoding) {
path = resolve(path); path = resolve(path);
return stat(path).then(function (stats) { return stat(path).then(function (stats) {
var item = cache[path]; var item = cache[path];
if (item && item.mtime.getTime() === stats.mtime.getTime()) { if (item && item.mtime.getTime() === stats.mtime.getTime()) {
return convert(item.content, encoding); return convert(item.content, encoding);
} }
return readFile(path).then(function (data) { return readFile(path).then(function (data) {
cache[path] = { cache[path] = {
mtime: stats.mtime, mtime: stats.mtime,
content: data content: data
}; };
return convert(data, encoding); return convert(data, encoding);
}); });
}).catch(function (err) { }).catch(function (err) {
cache[path] = null; cache[path] = null;
return Promise.reject(err); return Promise.reject(err);
}); });
}; };
readCache$1.exports.sync = function (path, encoding) { readCache$1.exports.sync = function (path, encoding) {
path = resolve(path); path = resolve(path);
try { try {
var stats = fs.statSync(path); var stats = fs.statSync(path);
var item = cache[path]; var item = cache[path];
if (item && item.mtime.getTime() === stats.mtime.getTime()) { if (item && item.mtime.getTime() === stats.mtime.getTime()) {
return convert(item.content, encoding); return convert(item.content, encoding);
} }
var data = fs.readFileSync(path); var data = fs.readFileSync(path);
cache[path] = { cache[path] = {
mtime: stats.mtime, mtime: stats.mtime,
content: data content: data
}; };
return convert(data, encoding); return convert(data, encoding);
} catch (err) { } catch (err) {
cache[path] = null; cache[path] = null;
throw err; throw err;
} }
}; };
readCache$1.exports.get = function (path, encoding) { readCache$1.exports.get = function (path, encoding) {
path = resolve(path); path = resolve(path);
if (cache[path]) { if (cache[path]) {
return convert(cache[path].content, encoding); return convert(cache[path].content, encoding);
} }
return null; return null;
}; };
readCache$1.exports.clear = function () { readCache$1.exports.clear = function () {
cache = Object.create(null); cache = Object.create(null);
}; };
const readCache = readCache$1.exports; const readCache = readCache$1.exports;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev-extern-backend": "vite --mode developement-external",
"build-dev": "vite build --mode development", "build-dev": "vite build --mode development",
"build-prod": "vite build --mode production", "build-prod": "vite build --mode production",
"build-embed": "vite build --mode production --base=/dashboard/", "build-embed": "vite build --mode production --base=/dashboard/",

View file

@ -1,83 +1,108 @@
<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>
<script> <script>
export default { export default {
data() { props: ["user_id", "name", "date", "comments", "likes", "photo_id", "liked"],
data: function () {
return { return {
my_id: sessionStorage.getItem("token"), // Data for the modal
modalTitle: "Modal Title",
modalMsg: "Modal Message",
// Whether the user is logged in
logged_in: true,
} }
}, },
methods: {
// Function to show a modal
// can be called by any view or component
// title: title of the modal
// message: message to show in the modal
showModal(title, message) {
// Set the modal data
this.modalTitle = title
this.modalMsg = message
// Show the modal
this.$refs.errModal.showModal()
},
// Sets the login status to true
// to show the navigation buttons
setLoggedIn() {
this.logged_in = true
},
// Disconnects the current logged in user
logout() {
localStorage.removeItem("token")
sessionStorage.removeItem("token")
this.logged_in = false
this.$router.push({ path: "/login" })
}
},
// Called when the root view is mounted
mounted() {
// Check if the user is already logged in
this.$axiosUpdate()
// Configure axios interceptors
this.$axios.interceptors.response.use(response => {
// Leave response as is
return response;
}, error => {
if (error.response.status != 0) {
// If the response is 401, redirect to /login
if (error.response.status === 401) {
this.$router.push({ path: '/login' })
this.logged_in = false;
return;
}
// Show the error message from the server in a modal
this.showModal("Error " + error.response.status, error.response.data['status'])
return;
}
// Show the error message from axios in a modal
this.showModal("Error", error.toString());
return;
});
}
} }
</script> </script>
<template> <template>
<!-- Modal to show error messages -->
<!--<header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow"> <Modal ref="errModal" id="errorModal" :title="modalTitle">
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3 fs-6" href="#/">WASAPhoto</a> {{ modalMsg }}
<button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"> </Modal>
<span class="navbar-toggler-icon"></span>
</button>
</header>-->
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<!--<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse"> <main>
<div class="position-sticky pt-3 sidebar-sticky"> <!-- The view is rendered here -->
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted text-uppercase">
<span>WASAPhoto</span>
</h6>
<ul class="nav flex-column">
<li class="nav-item">
<RouterLink to="/" class="nav-link">
<svg class="feather"><use href="/feather-sprite-v4.29.0.svg#home"/></svg>
Stream
</RouterLink>
</li>
<li class="nav-item">
<RouterLink to="/link1" class="nav-link">
<svg class="feather"><use href="/feather-sprite-v4.29.0.svg#layout"/></svg>
Search
</RouterLink>
</li>
</ul>
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted text-uppercase">
<span>Account</span>
</h6>
<ul class="nav flex-column">
<li class="nav-item">
<RouterLink :to="'/some/' + 'variable_here' + '/path'" class="nav-link">
<svg class="feather"><use href="/feather-sprite-v4.29.0.svg#file-text"/></svg>
Your profile
</RouterLink>
</li>
</ul>
</div>
</nav>-->
<!---<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">-->
<main class="mb-5">
<RouterView /> <RouterView />
<div v-if="logged_in" class="mb-5 pb-3"></div> <!-- Empty div to avoid hiding items under the navbar. todo: find a better way to do this -->
</main> </main>
<nav id="global-nav" class="navbar fixed-bottom navbar-light bg-light row"> <!-- Bottom navigation buttons -->
<nav v-if="logged_in" id="global-nav" class="navbar fixed-bottom navbar-light bg-light">
<div class="collapse navbar-collapse" id="navbarNav"></div> <div class="collapse navbar-collapse" id="navbarNav"></div>
<RouterLink to="/" class="col-4 text-center"> <RouterLink to="/" class="col-4 text-center">
<i class="bi bi-house text-dark" style="font-size: 2em"></i> <i class="bi bi-house text-dark" style="font-size: 2em"></i>
</RouterLink> </RouterLink>
<RouterLink to="/search" class="col-4 text-center"> <RouterLink to="/search" class="col-4 text-center">
<i class="bi bi-search text-dark" style="font-size: 2em"></i> <i class="bi bi-search text-dark" style="font-size: 2em"></i>
</RouterLink> </RouterLink>
<RouterLink :to="'/profile/' + my_id" class="col-4 text-center"> <RouterLink to="/profile/me" class="col-4 text-center">
<i class="bi bi-person text-dark" style="font-size: 2em"></i> <i class="bi bi-person text-dark" style="font-size: 2em"></i>
</RouterLink> </RouterLink>
</nav> </nav>
</div> </div>
</div> </div>
</template> </template>
<style> <style>
/* Make the active navigation button a little bit bigger */
#global-nav a.router-link-active { #global-nav a.router-link-active {
font-size: 1.2em font-size: 1.2em
} }

View file

@ -1,10 +1,12 @@
<script> <script>
export default { export default {
// The error message to display
props: ['msg'] props: ['msg']
} }
</script> </script>
<template> <template>
<!-- This component renders an error message -->
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
{{ msg }} {{ msg }}
</div> </div>

View file

@ -0,0 +1,49 @@
<script>
// This component emits an event when the sentinal element is intersecting the viewport
// (used to load more content when the user scrolls down)
// This component uses JavaScript's IntersectionObserver API
export default {
name: 'IntersectionObserver',
props: {
sentinalName: {
type: String,
required: true,
},
},
data() {
return {
// Whether the sentinal element is intersecting the viewport
isIntersectingElement: false,
}
},
watch: {
// Emit an event when the sentinal element is intersecting the viewport
isIntersectingElement: function (value) {
if (!value) return
this.$emit('on-intersection-element')
},
},
mounted() {
const sentinal = this.$refs[this.sentinalName]
// Create an observer to check if the sentinal element is intersecting the viewport
const handler = (entries) => {
if (entries[0].isIntersecting) {
this.isIntersectingElement = true
}
else {
this.isIntersectingElement = false
}
}
const observer = new window.IntersectionObserver(handler)
observer.observe(sentinal)
},
}
</script>
<template>
<!-- The sentinal element -->
<div :ref="sentinalName" class="w-full h-px relative" />
</template>

View file

@ -5,6 +5,7 @@ export default {
</script> </script>
<template> <template>
<!-- This component renders a loading spinner if the loading prop is true -->
<div v-if="loading"> <div v-if="loading">
<div style="text-align: center"> <div style="text-align: center">
<div class="spinner-grow" role="status"> <div class="spinner-grow" role="status">
@ -14,5 +15,3 @@ export default {
</div> </div>
<div v-if="!loading"><slot /></div> <div v-if="!loading"><slot /></div>
</template> </template>
<style></style>

View file

@ -0,0 +1,43 @@
<script>
export default {
props: ["id", "title"],
methods: {
// Visit the user's profile
showModal() {
this.$refs['open' + this.id].click();
},
},
}
</script>
<template>
<!-- This components renders a Bootstrap modal -->
<!-- The modal contains a title and a message -->
<!-- Invisible button to open the modal -->
<button :ref="'open' + id" type="button" class="btn btn-primary" style="display: none" data-bs-toggle="modal" :data-bs-target="'#' + id" />
<!-- Modal -->
<div class="modal fade" :id="id" tabindex="-1" :aria-labelledby="id + 'Label'" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<!-- Modal title -->
<h5 class="modal-title" :id="id + 'Label'">{{ title }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<!-- Modal body -->
<div class="modal-body">
<slot />
</div>
<!-- Footer with close button -->
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</template>

View file

@ -1,89 +1,199 @@
<script> <script>
export default { export default {
props: ["user_id", "name", "date", "comments", "likes", "photo_id", "liked", "my_id"], props: ["user_id", "name", "date", "comments", "likes", "photo_id", "liked"],
data: function() { data: function () {
return { return {
imageSrc: "", // Whether the image is loaded (disables the spinner)
errorMsg: null, imageReady: false,
// Likes and comments
post_liked: this.liked, post_liked: this.liked,
post_like_cnt: this.likes, post_like_cnt: this.likes,
post_comments_cnt: this.comments,
comments_data: [],
comments_start_idx: 0,
comments_shown: false,
commentMsg: "",
// Whether the comments have ended (no more comments to load)
data_ended: false,
} }
}, },
methods: { methods: {
// Visit the user's profile
visitUser() {
this.$router.push({ path: "/profile/" + this.user_id });
},
// Post a new comment
postComment() {
this.$axios.post("/users/" + this.user_id + "/photos/" + this.photo_id + "/comments", {
"comment": this.commentMsg,
"user_id": this.$currentSession(),
}).then(response => {
if (response == null) return // the interceptors returns null if something goes bad
// Reset the comment input and update the counter
this.commentMsg = "";
this.post_comments_cnt++;
// Fetch comments from the server
this.comments_data = [];
this.comments_start_idx = 0;
this.getComments();
})
},
// Show or hide the comments section
showHideComments() {
// If comments are already shown, hide them and reset the data
if (this.comments_shown) {
this.comments_shown = false;
this.comments_data = [];
this.comments_start_idx = 0;
return;
}
this.getComments();
},
// Fetch comments from the server
getComments() {
this.data_ended = false
this.$axios.get("/users/" + this.user_id + "/photos/" + this.photo_id +
"/comments?limit=5&start_index=" + this.comments_start_idx).then(response => {
// If there are no more comments, set the flag
if (response.data.length == 0 || response.data.length < 5) this.data_ended = true;
// Otherwise increment the start index
else this.comments_start_idx += 5;
// Append the comments to the array (they will be rendered)
this.comments_data = this.comments_data.concat(response.data);
this.comments_shown = true;
})
},
// Like the photo
like() { like() {
this.$axios.put("/users/" + this.user_id + "/photos/" + this.photo_id + "/likes/" + this.my_id).then(response => { this.$axios.put("/users/" + this.user_id + "/photos/" + this.photo_id + "/likes/" + this.$currentSession()).then(response => {
if (response == null) return
this.post_liked = true; this.post_liked = true;
this.post_like_cnt++; this.post_like_cnt++;
}).catch(error => { })
console.log(error);
this.errorMsg = error.toString();
});
}, },
// Unlike the photo
unlike() { unlike() {
this.$axios.delete("/users/" + this.user_id + "/photos/" + this.photo_id + "/likes/" + this.my_id).then(response => { this.$axios.delete("/users/" + this.user_id + "/photos/" + this.photo_id + "/likes/" + this.$currentSession()).then(response => {
if (response == null) return
this.post_liked = false; this.post_liked = false;
this.post_like_cnt--; this.post_like_cnt--;
}).catch(error => { })
console.log(error);
this.errorMsg = error.toString();
});
}, },
}, },
created() { created() {
this.$axios.get("/users/" + this.user_id + "/photos/" + this.photo_id, { // Fetch the image from the server and display it
responseType: 'arraybuffer' this.$axios.get("/users/" + this.user_id + "/photos/" + this.photo_id, {
}).then(response => { responseType: 'arraybuffer'
const img = document.createElement('img'); }).then(response => {
img.src = URL.createObjectURL(new Blob([response.data])); // Create an image element and append it to the container
img.classList.add("card-img-top"); const img = document.createElement('img');
this.$refs.imageContainer.appendChild(img);
}); // Set image source and css class
}, img.src = URL.createObjectURL(new Blob([response.data]));
} img.classList.add("card-img-top");
// Append the image to the container and disable the spinner
this.$refs.imageContainer.appendChild(img);
this.imageReady = true;
});
},
}
</script> </script>
<template> <template>
<div class="card mb-5"> <div class="card mb-5">
<!--<img v-auth-img="imageSrc" class="card-img-top" alt="Chicago Skyscrapers"/>-->
<div ref="imageContainer"></div> <!-- Image container div -->
<div ref="imageContainer">
<div v-if="!imageReady" class="mt-3 mb-3">
<LoadingSpinner :loading="!imageReady" />
</div>
</div>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<!-- Username and date -->
<div class="col-10"> <div class="col-10">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">{{ name }}</h5> <h5 @click="visitUser" class="card-title d-inline-block" style="cursor: pointer">{{ name }}</h5>
<p class="card-text">{{ new Date(Date.parse(date)) }}</p> <p class="card-text">{{ new Date(Date.parse(date)) }}</p>
</div> </div>
</div> </div>
<!-- Comment and like buttons -->
<div class="col-2"> <div class="col-2">
<div class="card-body d-flex justify-content-end" style="display: inline-flex"> <!-- not quite sure flex is the right property, but it works --> <div class="card-body d-flex justify-content-end" style="display: inline-flex">
<a><h5><i class="card-title bi bi-chat-right pe-1"></i></h5></a> <a @click="showHideComments">
<h6 class="card-text d-flex align-items-end text-muted">{{ comments }}</h6> <h5><i class="card-title bi bi-chat-right pe-1"></i></h5>
<a v-if="!post_liked" @click="like"><h5><i class="card-title bi bi-suit-heart ps-2 pe-1 like-icon"></i></h5></a> </a>
<a v-if="post_liked" @click="unlike"><h5><i class="card-title bi bi-heart-fill ps-2 pe-1 like-icon like-red"></i></h5></a> <h6 class="card-text d-flex align-items-end text-muted">{{ post_comments_cnt }}</h6>
<a v-if="!post_liked" @click="like">
<h5><i class="card-title bi bi-suit-heart ps-2 pe-1 like-icon"></i></h5>
</a>
<a v-if="post_liked" @click="unlike">
<h5><i class="card-title bi bi-heart-fill ps-2 pe-1 like-icon like-red"></i></h5>
</a>
<h6 class="card-text d-flex align-items-end text-muted">{{ post_like_cnt }}</h6> <h6 class="card-text d-flex align-items-end text-muted">{{ post_like_cnt }}</h6>
<h5></h5> <h5></h5>
</div> </div>
</div> </div>
</div> </div>
</div>
<!--<ul class="list-group list-group-light list-group-small"> <!-- Comments section -->
<li class="list-group-item px-4">Cras justo odio</li> <div v-if="comments_shown">
<li class="list-group-item px-4">Dapibus ac facilisis in</li> <div v-for="item of comments_data" class="row" v-bind:key="item.comment_id">
<li class="list-group-item px-4">Vestibulum at eros</li> <div class="col-7 card-body border-top">
</ul>--> <b>{{ item.name }}:</b> {{ item.comment }}
</div>
<div class="col-5 card-body border-top text-end text-secondary">
{{ new Date(Date.parse(item.date)).toDateString() }}
</div>
</div>
<!-- Show more comments label -->
<div v-if="!data_ended" class="col-12 card-body text-end pt-0 pb-1 px-0">
<a @click="getComments" class="text-primary">Show more comments...</a>
</div>
<!-- New comment form -->
<div class="row">
<!-- Comment input -->
<div class="col-10 card-body border-top text-end">
<input v-model="commentMsg" type="text" class="form-control" placeholder="Commenta...">
</div>
<!-- Comment publish button -->
<div class="col-1 card-body border-top text-end ps-0 d-flex">
<button style="width: 100%" type="button" class="btn btn-primary"
@click="postComment">Go</button>
</div>
</div>
</div>
</div>
</div> </div>
<ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg>
</template> </template>
<style> <style>
.like-icon:hover { .like-icon:hover {
color: #ff0000; color: #ff0000;
} }
.like-red { .like-red {
color: #ff0000; color: #ff0000;
} }

View file

@ -0,0 +1,107 @@
<script>
export default {
props: ["user_data"],
data: function () {
return {
modal_data: [],
data_type: "followers",
// Dynamic loading parameters
data_ended: false,
start_idx: 0,
limit: 10,
loading: false,
};
},
methods: {
// Visit the profile of the user with the given id
visit(user_id) {
this.$router.push({ path: "/profile/" + user_id })
},
// Reset the current data and fetch the first batch of users
// then show the modal
async loadData(type) {
// Reset the parameters and the users array
this.data_type = type
this.start_idx = 0
this.data_ended = false
this.modal_data = []
// Fetch the first batch of users
let status = await this.loadContent()
// Show the modal if the request was successful
if (status) this.$refs["mymodal"].showModal()
// If the request fails, the interceptor will show the error modal
},
// Fetch users from the server
async loadContent() {
// Fetch followers / following from the server
// uses /followers and /following endpoints
let response = await this.$axios.get("/users/" + this.user_data["user_id"] + "/" + this.data_type + "?start_index=" + this.start_idx + "&limit=" + this.limit)
if (response == null) return false // An error occurred. The interceptor will show a modal
// If the server returned less elements than requested,
// it means that there are no more photos to load
if (response.data.length == 0 || response.data.length < this.limit)
this.data_ended = true
// Append the new photos to the array
this.modal_data = this.modal_data.concat(response.data)
return true
},
// Load more users when the user scrolls to the bottom
loadMore() {
// Avoid sending a request if there are no more photos
if (this.loading || this.data_ended) return
// Increase the start index and load more photos
this.start_idx += this.limit
this.loadContent()
},
},
}
</script>
<template>
<!-- Modal to show the followers / following -->
<Modal ref="mymodal" id="userModal" :title="data_type">
<ul>
<li v-for="item in modal_data" :key="item.user_id" class="mb-2" style="cursor: pointer"
@click="visit(item.user_id)" data-bs-dismiss="modal">
<h5>{{ item.name }}</h5>
</li>
<IntersectionObserver sentinal-name="load-more-users" @on-intersection-element="loadMore" />
</ul>
</Modal>
<!-- Profile counters -->
<div class="row text-center mt-2 mb-3">
<!-- Photos counter -->
<div class="col-4" style="border-right: 1px">
<h3>{{ user_data["photos"] }}</h3>
<h6>Photos</h6>
</div>
<!-- Followers counter -->
<div class="col-4" @click="loadData('followers')" style="cursor: pointer">
<h3>{{ user_data["followers"] }}</h3>
<h6>Followers</h6>
</div>
<!-- Following counter -->
<div class="col-4" @click="loadData('following')" style="cursor: pointer">
<h3>{{ user_data["following"] }}</h3>
<h6>Following</h6>
</div>
</div>
</template>

View file

@ -1,118 +1,216 @@
<script> <script>
export default { export default {
props: ["user_id", "name", "followed", "banned", "my_id", "show_new_post"], props: ["user_id", "name", "followed", "banned", "show_new_post"],
watch: { watch: {
banned: function(new_val, old_val) { name: function (new_val, old_val) {
this.user_banned = new_val; this.username = new_val
}, },
followed: function(new_val, old_val) { banned: function (new_val, old_val) {
this.user_followed = new_val; this.user_banned = new_val
},
followed: function (new_val, old_val) {
this.user_followed = new_val
},
user_id: function (new_val, old_val) {
this.myself = this.$currentSession() == new_val
}, },
}, },
data: function() { data: function () {
return { return {
errorMsg: "aaa", // User data
user_followed: this.followed, username: this.name,
user_banned: this.banned, user_followed: this.followed,
myself: this.my_id == this.user_id, user_banned: this.banned,
// Whether the user is the currently logged in user
myself: this.$currentSession() == this.user_id,
// Whether to show the buttons to post a new photo and update the username
show_post_form: false, show_post_form: false,
show_username_form: false,
// The new username
newUsername: "",
// The file to upload
upload_file: null, upload_file: null,
} }
}, },
methods: { methods: {
// Logout the user
logout() {
this.$root.logout()
},
// Visit the user's profile
visit() { visit() {
this.$router.push({ path: "/profile/" + this.user_id }); this.$router.push({ path: "/profile/" + this.user_id });
}, },
follow() {
this.$axios.put("/users/" + this.user_id + "/followers/" + this.my_id) // Follow the user
.then(response => { follow() {
this.user_followed = true this.$axios.put("/users/" + this.user_id + "/followers/" + this.$currentSession())
this.$emit('updateInfo') .then(response => {
}) if (response == null) return // the interceptors returns null if something goes bad
.catch(error => alert(error.toString())); this.user_followed = true
}, this.$emit('updateInfo')
unfollow() { })
this.$axios.delete("/users/" + this.user_id + "/followers/" + this.my_id) },
.then(response => {
this.user_followed = false // Unfollow the user
this.$emit('updateInfo') unfollow() {
}) this.$axios.delete("/users/" + this.user_id + "/followers/" + this.$currentSession())
.catch(error => alert(error.toString())); .then(response => {
}, if (response == null) return
ban() { this.user_followed = false
this.$axios.put("/users/" + this.my_id + "/bans/" + this.user_id) this.$emit('updateInfo')
.then(response => { })
this.user_banned = true },
this.$emit('updateInfo')
}) // Ban the user
.catch(error => alert(error.toString())); ban() {
}, this.$axios.put("/users/" + this.$currentSession() + "/bans/" + this.user_id)
unban() { .then(response => {
this.$axios.delete("/users/" + this.my_id + "/bans/" + this.user_id) if (response == null) return
.then(response => { this.user_banned = true
this.user_banned = false this.$emit('updateInfo')
this.$emit('updateInfo') })
}) },
.catch(error => alert(error.toString()));
}, // Unban the user
unban() {
this.$axios.delete("/users/" + this.$currentSession() + "/bans/" + this.user_id)
.then(response => {
if (response == null) return
this.user_banned = false
this.$emit('updateInfo')
})
},
// Prepare the file to upload
load_file(e) { load_file(e) {
let files = e.target.files || e.dataTransfer.files; let files = e.target.files || e.dataTransfer.files;
if (!files.length) return; if (!files.length) return
this.upload_file = files[0]; this.upload_file = files[0]
}, },
// Upload the file
submit_file() { submit_file() {
this.$axios.post("/users/" + this.my_id + "/photos", this.upload_file) this.$axios.post("/users/" + this.$currentSession() + "/photos", this.upload_file)
.then(response => { .then(response => {
this.show_post_form = false if (response == null) return
this.$emit('updatePosts') this.show_post_form = false
}) this.$emit('updatePosts')
.catch(error => alert(error.toString())); })
},
// Update the username
updateUsername() {
this.$axios.put("/users/" + this.$currentSession() + "/username", { name: this.newUsername })
.then(response => {
if (response == null) return
this.show_username_form = false
this.$emit('updateInfo')
this.username = this.newUsername
})
}, },
},
created() {
}, },
} }
</script> </script>
<template> <template>
<div class="card mb-3"> <div class="card mb-3">
<div class="container">
<div class="row">
<div class="col-5">
<div class="card-body h-100 d-flex align-items-center">
<a @click="visit">
<h5 class="card-title mb-0 d-inline-block" style="cursor: pointer">{{ username }}</h5>
</a>
</div>
</div>
<div class="container"> <!-- Whether to show one or two rows -->
<div class="row"> <div class="d-flex flex-column" v-bind:class="{
<div class="col-10"> 'col-12': (myself && show_new_post),
<div class="card-body h-100 d-flex align-items-center"> 'col-sm-7': (myself && show_new_post),
<a @click="visit"><h5 class="card-title mb-0">{{ name }}</h5></a> 'col-7': !(myself && show_new_post),
</div> 'align-items-end': !(myself && show_new_post),
</div> 'align-items-sm-end': (myself && show_new_post),
}">
<div class="col-2"> <!-- Buttons -->
<div class="card-body d-flex justify-content-end"> <div class="card-body d-flex">
<div v-if="!myself" class="d-flex"> <div v-if="!myself" class="d-flex">
<button v-if="!user_banned" @click="ban" type="button" class="btn btn-outline-danger me-2">Ban</button> <button v-if="!user_banned" @click="ban" type="button"
<button v-if="user_banned" @click="unban" type="button" class="btn btn-danger me-2">Banned</button> class="btn btn-outline-danger me-2">Ban</button>
<button v-if="!user_followed" @click="follow" type="button" class="btn btn-primary">Follow</button> <button v-if="user_banned" @click="unban" type="button"
<button v-if="user_followed" @click="unfollow" type="button" class="btn btn-outline-primary">Following</button> class="btn btn-danger me-2">Banned</button>
<button v-if="!user_followed" @click="follow" type="button"
class="btn btn-primary">Follow</button>
<button v-if="user_followed" @click="unfollow" type="button"
class="btn btn-outline-primary">Following</button>
</div> </div>
<!-- Users cannot follow or ban themselves -->
<div v-if="(myself && !show_new_post)"> <div v-if="(myself && !show_new_post)">
<button disabled type="button" class="btn btn-secondary">Yourself</button> <button disabled type="button" class="btn btn-secondary">Yourself</button>
</div> </div>
<div v-if="(myself && show_new_post)" class="d-flex">
<button v-if="!show_post_form" type="button" class="btn btn-primary" @click="show_post_form = true">Post</button> <!-- Logout button -->
<div v-if="(myself && show_new_post)" class="col">
<button type="button" class="btn btn-outline-danger me-2" @click="logout">Logout</button>
</div>
<div class="d-flex col justify-content-end flex-row">
<!-- Update username button -->
<div v-if="(myself && show_new_post)" class="">
<button v-if="!show_username_form" type="button" class="btn btn-outline-secondary me-2"
@click="show_username_form = true">Username</button>
</div>
<!-- Post a new photo button -->
<div v-if="(myself && show_new_post)" class="">
<button v-if="!show_post_form" type="button" class="btn btn-primary"
@click="show_post_form = true">Post</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="row" v-if="show_post_form">
<div class="col-9">
<div class="card-body h-100 d-flex align-items-center">
<input @change="load_file" class="form-control form-control-lg" id="formFileLg" type="file" />
</div>
</div>
<div class="col-3"> <!-- File input -->
<div class="card-body d-flex justify-content-end"> <div class="row" v-if="show_post_form">
<button type="button" class="btn btn-primary btn-lg" @click="submit_file">Publish</button> <div class="col-9">
<div class="card-body h-100 d-flex align-items-center">
<input @change="load_file" class="form-control form-control-lg" id="formFileLg" type="file" />
</div>
</div>
<!-- Publish button -->
<div class="col-3">
<div class="card-body d-flex justify-content-end">
<button type="button" class="btn btn-primary btn-lg" @click="submit_file">Publish</button>
</div>
</div>
</div>
<!-- New username form -->
<div class="row" v-if="show_username_form">
<!-- Username input -->
<div class="col-10">
<div class="card-body h-100 d-flex align-items-center">
<input v-model="newUsername" class="form-control form-control-lg" id="formUsername"
placeholder="Your new fantastic username! 😜" />
</div>
</div>
<!-- Username update button -->
<div class="col-2">
<div class="card-body d-flex justify-content-end">
<button type="button" class="btn btn-primary btn-lg" @click="updateUsername">Set</button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -2,21 +2,33 @@ import {createApp, reactive} from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { axios, updateToken as axiosUpdate } from './services/axios.js'; import { axios, updateToken as axiosUpdate } from './services/axios.js';
import getCurrentSession from './services/authentication';
import ErrorMsg from './components/ErrorMsg.vue' import ErrorMsg from './components/ErrorMsg.vue'
import LoadingSpinner from './components/LoadingSpinner.vue' import LoadingSpinner from './components/LoadingSpinner.vue'
import PostCard from './components/PostCard.vue' import PostCard from './components/PostCard.vue'
import UserCard from './components/UserCard.vue' import UserCard from './components/UserCard.vue'
import ProfileCounters from './components/ProfileCounters.vue'
import Modal from './components/Modal.vue'
import IntersectionObserver from './components/IntersectionObserver.vue'
import 'bootstrap-icons/font/bootstrap-icons.css' import 'bootstrap-icons/font/bootstrap-icons.css'
import './assets/dashboard.css' import './assets/dashboard.css'
import './assets/main.css' import './assets/main.css'
// Create the Vue SPA
const app = createApp(App) const app = createApp(App)
app.config.globalProperties.$axios = axios; app.config.globalProperties.$axios = axios;
app.config.globalProperties.$axiosUpdate = axiosUpdate; app.config.globalProperties.$axiosUpdate = axiosUpdate;
app.config.globalProperties.$currentSession = getCurrentSession;
// Register the components
app.component("ErrorMsg", ErrorMsg); app.component("ErrorMsg", ErrorMsg);
app.component("LoadingSpinner", LoadingSpinner); app.component("LoadingSpinner", LoadingSpinner);
app.component("PostCard", PostCard); app.component("PostCard", PostCard);
app.component("UserCard", UserCard); app.component("UserCard", UserCard);
app.component("ProfileCounters", ProfileCounters);
app.component("Modal", Modal);
app.component("IntersectionObserver", IntersectionObserver);
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')

View file

@ -0,0 +1,4 @@
export default function getCurrentSession() {
if (localStorage.getItem('token') == null) return sessionStorage.getItem('token');
return localStorage.getItem('token');
}

View file

@ -1,8 +1,9 @@
import axios from "axios"; import axios from "axios";
import getCurrentSession from "./authentication";
const instance = axios.create({ const instance = axios.create({
baseURL: __API_URL__, baseURL: __API_URL__,
timeout: 1000 * 5 timeout: 1000 * 60
}); });
//axios.interceptors.request.use(function (config) { //axios.interceptors.request.use(function (config) {
@ -13,7 +14,7 @@ const instance = axios.create({
//}); //});
const updateToken = () => { const updateToken = () => {
instance.defaults.headers.common['Authorization'] = 'Bearer ' + sessionStorage.getItem('token'); instance.defaults.headers.common['Authorization'] = 'Bearer ' + getCurrentSession();
} }
export { export {

View file

@ -1,56 +1,82 @@
<script> <script>
export default { export default {
data: function() { data: function () {
return { return {
errormsg: null, // Whether the content is loading
// to show the loading spinner
loading: false, loading: false,
// Stream data from the server
stream_data: [], stream_data: [],
// Whether the data has ended
// to stop loading more data with the infinite scroll
data_ended: false, data_ended: false,
// Parameters to load data dynamically when scrolling
start_idx: 0, start_idx: 0,
limit: 1, limit: 1,
my_id: sessionStorage.getItem("token"),
// Shows the retry button
loadingError: false,
} }
}, },
methods: { methods: {
// Reload the whole page content
// fetching it again from the server
async refresh() { async refresh() {
// Limits the number of posts to load based on the window height
// to avoid loading too many posts at once
// 450px is (a bit more) of the height of a single post
this.limit = Math.round(window.innerHeight / 450); this.limit = Math.round(window.innerHeight / 450);
// Reset the parameters and the data
this.start_idx = 0; this.start_idx = 0;
this.data_ended = false; this.data_ended = false;
this.stream_data = []; this.stream_data = [];
// Fetch the first batch of posts
this.loadContent(); this.loadContent();
}, },
// Requests data from the server asynchronously
async loadContent() { async loadContent() {
this.loading = true; this.loading = true;
this.errormsg = null;
try { let response = await this.$axios.get("/stream?start_index=" + this.start_idx + "&limit=" + this.limit);
let response = await this.$axios.get("/stream?start_index=" + this.start_idx + "&limit=" + this.limit);
if (response.data.length == 0) this.data_ended = true; // Errors are handled by the interceptor, which shows a modal dialog to the user and returns a null response.
else this.stream_data = this.stream_data.concat(response.data); if (response == null) {
this.loading = false; this.loading = false
} catch (e) { this.loadingError = true
if (e.response.status == 401) { return
this.$router.push({ path: "/login" });
}
this.errormsg = e.toString();
} }
// If the response is empty or shorter than the limit
// then there is no more data to load
if (response.data.length == 0 || response.data.length < this.limit) this.data_ended = true;
this.stream_data = this.stream_data.concat(response.data);
// Finished loading, hide the spinner
this.loading = false;
}, },
scroll () {
window.onscroll = () => { // Loads more data when the user scrolls down
let bottomOfWindow = Math.max(window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop) + window.innerHeight === document.documentElement.offsetHeight // (this is called by the IntersectionObserver component)
if (bottomOfWindow && !this.data_ended) { loadMore() {
this.start_idx += this.limit; // Avoid loading more content if the data has ended
this.loadContent(); if (this.loading || this.data_ended) return
}
} // Increase the start index and load more content
this.start_idx += this.limit
this.loadContent()
}, },
}, },
// Called when the view is mounted
mounted() { mounted() {
// this way we are sure that we fill the first page // Start loading the content
// 450 is a bit more of the max height of a post this.refresh();
// todo: may not work in 4k screens :/
this.limit = Math.round(window.innerHeight / 450);
this.scroll();
this.loadContent();
} }
} }
</script> </script>
@ -62,35 +88,40 @@ export default {
<div class="col-xl-6 col-lg-9"> <div class="col-xl-6 col-lg-9">
<h3 class="card-title border-bottom mb-4 pb-2 text-center">Your daily WASAStream!</h3> <h3 class="card-title border-bottom mb-4 pb-2 text-center">Your daily WASAStream!</h3>
<ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg> <!-- Show a message if there's no content to show -->
<div v-if="(stream_data.length == 0)" class="alert alert-secondary text-center" role="alert"> <div v-if="(stream_data.length == 0)" class="alert alert-secondary text-center" role="alert">
There's nothing here 😢 There's nothing here 😢
<br />Why don't you start following somebody? 👻 <br />Why don't you start following somebody? 👻
</div> </div>
<div id="main-content" v-for="item of stream_data"> <!-- The stream -->
<PostCard :user_id="item.user_id" <div id="main-content" v-for="item of stream_data" v-bind:key="item.photo_id">
:photo_id="item.photo_id" <!-- PostCard for each photo -->
:name="item.name" <PostCard :user_id="item.user_id" :photo_id="item.photo_id" :name="item.name" :date="item.date"
:date="item.date" :comments="item.comments" :likes="item.likes" :liked="item.liked" />
:comments="item.comments"
:likes="item.likes"
:liked="item.liked"
:my_id="my_id" />
</div> </div>
<div v-if="data_ended" class="alert alert-secondary text-center" role="alert"> <!-- Show a message if there's no more content to show -->
<div v-if="(data_ended && !(stream_data.length == 0))" class="alert alert-secondary text-center" role="alert">
This is the end of your stream. Hooray! 👻 This is the end of your stream. Hooray! 👻
</div> </div>
<!-- The loading spinner -->
<LoadingSpinner :loading="loading" /><br /> <LoadingSpinner :loading="loading" /><br />
<div class="d-flex align-items-center flex-column">
<!-- Retry button -->
<button v-if="loadingError" @click="refresh" class="btn btn-secondary w-100 py-3">Retry</button>
<!-- Load more button -->
<button v-if="(!data_ended && !loading)" @click="loadMore" class="btn btn-secondary py-1 mb-5"
style="border-radius: 15px">Load more</button>
<!-- The IntersectionObserver for dynamic loading -->
<IntersectionObserver sentinal-name="load-more-home" @on-intersection-element="loadMore" />
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style>
</style>

View file

@ -1,105 +1,120 @@
<script> <script>
import LoadingSpinner from '../components/LoadingSpinner.vue';
export default { export default {
data: function () { data: function () {
return { return {
// The error message to display
errormsg: null, errormsg: null,
// Loading spinner state
loading: false, loading: false,
some_data: null,
// Form inputs
field_username: "", field_username: "",
rememberLogin: false,
}; };
}, },
methods: { methods: {
// Send the login request to the server
// if the login is successful, the token is saved
// and the user is redirected to the previous page
async login() { async login() {
this.loading = true; this.loading = true;
this.errormsg = null; this.errormsg = null;
try {
let response = await this.$axios.post("/session", {
name: this.field_username,
});
//this.$router.push({ name: "home" });
if (response.status == 201 || response.status == 200) {
// Save the token in the session storage
sessionStorage.setItem("token", response.data["user_id"]);
// Update the header // Send the login request
this.$axiosUpdate(); let response = await this.$axios.post("/session", {
name: this.field_username,
});
this.$router.push({ path: "/" }); // Errors are handled by the interceptor, which shows a modal dialog to the user and returns a null response.
if (response == null) {
this.loading = false
return
}
// If the login is successful, save the token and redirect to the previous page
if (response.status == 201 || response.status == 200) {
// Save the token in the local storage if the user wants to be remembered
if (this.rememberLogin) {
localStorage.setItem("token", response.data["user_id"])
sessionStorage.removeItem("token");
} }
// Else save the token in the session storage
else { else {
this.errormsg = response.data["error"]; sessionStorage.setItem("token", response.data["user_id"]);
localStorage.removeItem("token");
} }
// Tell the root view to enable the navbar
this.$root.setLoggedIn();
// Update the header
this.$axiosUpdate();
// Go back to the previous page
this.$router.go(-1);
} }
catch (e) { else {
this.errormsg = e.toString(); // Login failed, show the error message
this.errormsg = response.data["error"];
} }
this.loading = false; // Disable the loading spinner
},
async refresh() {
//this.loading = true;
//this.errormsg = null;
//try {
// let response = await this.$axios.get("/");
// this.some_data = response.data;
//} catch (e) {
// this.errormsg = e.toString();
//}
this.loading = false; this.loading = false;
}, },
}, },
mounted() {
this.refresh();
},
components: { LoadingSpinner }
} }
</script> </script>
<template> <template>
<div class="vh-100 container py-5 h-100"> <!-- Login form centered in the page -->
<div class="row d-flex justify-content-center align-items-center h-100"> <div class="vh-100 container py-5 h-100">
<!--<div class="col-sm"><h2>* immagina un logo carino *</h2></div>--> <div class="row d-flex justify-content-center align-items-center h-100">
<div class="col-12 col-md-8 col-lg-6 col-xl-5"> <!--<div class="col-sm"><h2>* immagina un logo carino *</h2></div>-->
<div class="card" style="border-radius: 1rem"> <div class="col-12 col-md-8 col-lg-6 col-xl-5">
<div class="card-body p-4"> <div class="card" style="border-radius: 1rem">
<div class="card-body p-4">
<h1 class="h2 pb-4 text-center">WASAPhoto</h1> <h1 class="h2 pb-4 text-center">WASAPhoto</h1>
<form> <form>
<!-- Email input --> <!-- Email input -->
<div class="form-floating mb-4"> <div class="form-floating mb-4">
<input v-model="field_username" type="email" id="formUsername" class="form-control" placeholder="name@example.com"/> <input v-model="field_username" type="email" id="formUsername" class="form-control"
<label class="form-label" for="formUsername">Username</label> placeholder="name@example.com" />
</div> <label class="form-label" for="formUsername">Username</label>
</div>
<!-- Password input -->
<div class="form-floating mb-4"> <!-- Password input -->
<input disabled type="password" id="formPassword" class="form-control" placeholder="gattina12"/> <div class="form-floating mb-4">
<label class="form-label" for="formPassword">Password</label> <input style="display: none" disabled type="password" id="formPassword"
</div> class="form-control" placeholder="gattina12" />
<label style="display: none" class="form-label" for="formPassword">Password</label>
<!-- 2 column grid layout for inline styling --> </div>
<div class="row mb-4">
<div class="col d-flex justify-content-center"> <!-- 2 column grid layout for inline styling -->
<!-- Checkbox --> <div class="row mb-4">
<div class="form-check"> <div class="col d-flex justify-content-center">
<input class="form-check-input" type="checkbox" value="" id="form2Example31" checked /> <!-- Checkbox -->
<label class="form-check-label" for="form2Example31">Remember me</label> <div class="form-check">
</div> <input v-model="rememberLogin" class="form-check-input" type="checkbox" value=""
</div> id="form2Example31" />
</div> <label class="form-check-label" for="form2Example31">Remember me</label>
</div>
<!-- Submit button --> </div>
<button style="width: 100%" type="button" class="btn btn-primary btn-block mb-4" @click="login">Sign in</button> </div>
<ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg>
<LoadingSpinner :loading="loading" /> <!-- Submit button -->
</form> <button style="width: 100%" type="button" class="btn btn-primary btn-block mb-4"
</div> @click="login">Sign in</button>
<ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg>
<LoadingSpinner :loading="loading" />
<i class="text-center text-secondary d-flex flex-column">share your special moments!</i>
</form>
</div>
</div>
</div>
</div> </div>
</div></div> </div>
</div>
</template> </template>
<style> <style>
</style> </style>

View file

@ -1,72 +1,105 @@
<script> <script>
import IntersectionObserver from '../components/IntersectionObserver.vue';
export default { export default {
data: function() { data: function () {
return { return {
errormsg: null, // The profile to show
loading: false, requestedProfile: this.$route.params.user_id,
// Loading flags
loading: true,
loadingError: false,
// Profile data from the server
user_data: [],
// Protos data from the server
stream_data: [], stream_data: [],
// Dynamic loading parameters
data_ended: false, data_ended: false,
start_idx: 0, start_idx: 0,
limit: 1, limit: 1,
my_id: sessionStorage.getItem("token"), };
},
user_data: [], watch: {
'$route.params.user_id': {
handler: function (user_id) {
if (user_id !== null && user_id !== undefined) this.refresh()
},
deep: true,
immediate: true
} }
}, },
methods: { methods: {
async refresh() { async refresh() {
this.getMainData(); if (this.$route.params.user_id == "me") {
this.limit = Math.round(window.innerHeight / 450); // If the id is "me", show the current user's profile
this.start_idx = 0; this.requestedProfile = this.$currentSession()
this.data_ended = false; }
this.stream_data = []; else {
this.loadContent(); // Otherwise, show "id"'s profile
this.requestedProfile = this.$route.params.user_id
}
// Fetch profile info from the server
this.getMainData()
// Limits the number of posts to load based on the window height
// to avoid loading too many posts at once
// 450px is (a bit more) of the height of a single post
this.limit = Math.max(Math.round(window.innerHeight / 450), 1)
// Reset the parameters and the data
this.start_idx = 0
this.data_ended = false
this.stream_data = []
// Fetch the first batch of posts
this.loadContent()
}, },
// Fetch profile info from the server
async getMainData() {
let response = await this.$axios.get("/users/" + this.requestedProfile);
if (response == null) {
// An error occurred, set the error flag
this.loading = false
this.loadingError = true
return
}
this.user_data = response.data
},
async getMainData() { // Fetch photos from the server
try {
let response = await this.$axios.get("/users/" + this.$route.params.user_id);
this.user_data = response.data;
} catch(e) {
this.errormsg = e.toString();
}
},
async loadContent() { async loadContent() {
this.loading = true; this.loading = true;
this.errormsg = null; let response = await this.$axios.get("/users/" + this.requestedProfile + "/photos" + "?start_index=" + this.start_idx + "&limit=" + this.limit)
try { if (response == null) return // An error occurred. The interceptor will show a modal
let response = await this.$axios.get("/users/" + this.$route.params.user_id + "/photos" + "?start_index=" + this.start_idx + "&limit=" + this.limit);
if (response.data.length == 0) this.data_ended = true; // If the server returned less elements than requested,
else this.stream_data = this.stream_data.concat(response.data); // it means that there are no more photos to load
this.loading = false; if (response.data.length == 0 || response.data.length < this.limit)
} catch (e) { this.data_ended = true
if (e.response.status == 401) { // todo: move from here
this.$router.push({ path: "/login" }); // Append the new photos to the array
} this.stream_data = this.stream_data.concat(response.data)
this.errormsg = e.toString();
} // Disable the loading spinner
this.loading = false
}, },
scroll () {
window.onscroll = () => { // Load more photos when the user scrolls to the bottom of the page
let bottomOfWindow = Math.max(window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop) + window.innerHeight === document.documentElement.offsetHeight loadMore() {
if (bottomOfWindow && !this.data_ended) { // Avoid sending a request if there are no more photos
this.start_idx += this.limit; if (this.loading || this.data_ended) return
this.loadContent();
} // Increase the start index and load more photos
} this.start_idx += this.limit
this.loadContent()
}, },
}, },
mounted() {
// this way we are sure that we fill the first page
// 450 is a bit more of the max height of a post
// todo: may not work in 4k screens :/
this.getMainData();
this.limit = Math.round(window.innerHeight / 450);
this.scroll();
this.loadContent();
}
} }
</script> </script>
@ -77,54 +110,42 @@ export default {
<div class="row justify-content-md-center"> <div class="row justify-content-md-center">
<div class="col-xl-6 col-lg-9"> <div class="col-xl-6 col-lg-9">
<ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg> <!-- User card for profile info -->
<UserCard :user_id="requestedProfile" :name="user_data['name']" :followed="user_data['followed']"
:banned="user_data['banned']" :my_id="this.$currentSession" :show_new_post="true"
@updateInfo="getMainData" @updatePosts="refresh" />
<UserCard :user_id = "$route.params.user_id" <!-- Photos, followers and following counters -->
:name = "user_data['name']" <ProfileCounters :user_data="user_data" />
:followed = "user_data['followed']"
:banned = "user_data['banned']"
:my_id = "my_id"
:show_new_post = "true"
@updateInfo = "getMainData"
@updatePosts = "refresh" />
<div class="row text-center mt-2 mb-3">
<div class="col-4" style="border-right: 1px">
<h3>{{ user_data["photos"] }}</h3>
<h6>Photos</h6>
</div>
<div class="col-4">
<h3>{{ user_data["followers"] }}</h3>
<h6>Followers</h6>
</div>
<div class="col-4">
<h3>{{ user_data["following"] }}</h3>
<h6>Following</h6>
</div>
</div>
<div id="main-content" v-for="item of stream_data"> <!-- Photos -->
<PostCard :user_id = "$route.params.user_id" <div id="main-content" v-for="item of stream_data" v-bind:key="item.photo_id">
:photo_id = "item.photo_id" <!-- PostCard for the photo -->
:name = "user_data['name']" <PostCard :user_id="requestedProfile" :photo_id="item.photo_id" :name="user_data['name']"
:date = "item.date" :date="item.date" :comments="item.comments" :likes="item.likes" :liked="item.liked" />
:comments = "item.comments"
:likes = "item.likes"
:liked = "item.liked"
:my_id = "my_id" />
</div> </div>
<!-- Message when the end is reached -->
<div v-if="data_ended" class="alert alert-secondary text-center" role="alert"> <div v-if="data_ended" class="alert alert-secondary text-center" role="alert">
Hai visualizzato tutti i post. Hooray! 👻 You reached the end. Hooray! 👻
</div> </div>
<LoadingSpinner :loading="loading" /><br /> <!-- The loading spinner -->
<LoadingSpinner :loading="loading" />
<div class="d-flex align-items-center flex-column">
<!-- Refresh button -->
<button v-if="loadingError" @click="refresh" class="btn btn-secondary w-100 py-3">Retry</button>
<!-- Load more button -->
<button v-if="(!data_ended && !loading)" @click="loadMore" class="btn btn-secondary py-1 mb-5"
style="border-radius: 15px">Load more</button>
<!-- The IntersectionObserver for dynamic loading -->
<IntersectionObserver sentinal-name="load-more-profile" @on-intersection-element="loadMore" />
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style>
</style>

View file

@ -1,62 +1,79 @@
<script> <script>
// import getCurrentSession from '../services/authentication'; todo: can be removed
export default { export default {
data: function() { data: function () {
return { return {
// The error message to display
errormsg: null, errormsg: null,
loading: false, loading: false,
stream_data: [],
data_ended: false, // Search results
start_idx: 0, streamData: [],
// Dynamic loading
dataEnded: false,
startIdx: 0,
limit: 1, limit: 1,
field_username: "",
my_id: sessionStorage.getItem("token"), // Search input
fieldUsername: "",
} }
}, },
methods: { methods: {
async refresh() { // Reset the results and fetch the new requested ones
async query() {
// Set the limit to the number of cards that can fit in the window
this.limit = Math.round(window.innerHeight / 72); this.limit = Math.round(window.innerHeight / 72);
this.start_idx = 0;
this.data_ended = false; // Reset the parameters and the data
this.stream_data = []; this.startIdx = 0;
this.dataEnded = false;
this.streamData = [];
// Fetch the first batch of results
this.loadContent(); this.loadContent();
}, },
// Fetch the search results from the server
async loadContent() { async loadContent() {
this.loading = true; this.loading = true;
this.errormsg = null; this.errormsg = null;
if (this.field_username == "") {
// Check if the username is empty
// and show an error message
if (this.fieldUsername == "") {
this.errormsg = "Please enter a username"; this.errormsg = "Please enter a username";
this.loading = false; this.loading = false;
return; return;
} }
try {
let response = await this.$axios.get("/users?query=" + this.field_username + "&start_index=" + this.start_idx + "&limit=" + this.limit); // Fetch the results from the server
if (response.data.length == 0) this.data_ended = true; let response = await this.$axios.get("/users?query=" + this.fieldUsername + "&start_index=" + this.startIdx + "&limit=" + this.limit);
else this.stream_data = this.stream_data.concat(response.data);
this.loading = false; // Errors are handled by the interceptor, which shows a modal dialog to the user and returns a null response.
} catch (e) { if (response == null) {
this.errormsg = e.toString(); this.loading = false
if (e.response.status == 401) { return
this.$router.push({ path: "/login" });
}
} }
// If there are no more results, set the dataEnded flag
if (response.data.length == 0) this.dataEnded = true;
// Otherwise, append the new results to the array
else this.streamData = this.streamData.concat(response.data);
// Hide the loading spinner
this.loading = false;
}, },
scroll () {
window.onscroll = () => { // Load a new batch of results when the user scrolls to the bottom of the page
let bottomOfWindow = Math.max(window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop) + window.innerHeight === document.documentElement.offsetHeight loadMore() {
if (bottomOfWindow && !this.data_ended) { if (this.loading || this.dataEnded) return
this.start_idx += this.limit; this.startIdx += this.limit
this.loadContent(); this.loadContent()
}
}
}, },
}, },
mounted() {
// this way we are sure that we fill the first page
// 72 is a bit more of the max height of a card
// todo: may not work in 4k screens :/
this.limit = Math.round(window.innerHeight / 72);
this.scroll();
}
} }
</script> </script>
@ -68,23 +85,28 @@ export default {
<h3 class="card-title border-bottom mb-4 pb-2 text-center">WASASearch</h3> <h3 class="card-title border-bottom mb-4 pb-2 text-center">WASASearch</h3>
<!-- Error message -->
<ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg> <ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg>
<div class="form-floating mb-4"> <!-- Search form -->
<input v-model="field_username" @input="refresh" id="formUsername" class="form-control" placeholder="name@example.com"/> <div class="form-floating mb-4">
<label class="form-label" for="formUsername">Search by username</label> <input v-model="fieldUsername" @input="query" id="formUsername" class="form-control"
</div> placeholder="name@example.com" />
<label class="form-label" for="formUsername">Search by username</label>
<div id="main-content" v-for="item of stream_data">
<UserCard
:user_id="item.user_id"
:name="item.name"
:followed="item.followed"
:banned="item.banned"
:my_id="my_id" />
</div> </div>
<!-- Search results -->
<div id="main-content" v-for="item of streamData" v-bind:key="item.user_id">
<!-- User card (search result entry) -->
<UserCard :user_id="item.user_id" :name="item.name" :followed="item.followed"
:banned="item.banned" />
</div>
<!-- Loading spinner -->
<LoadingSpinner :loading="loading" /><br /> <LoadingSpinner :loading="loading" /><br />
<!-- The IntersectionObserver for dynamic loading -->
<IntersectionObserver sentinal-name="load-more-search" @on-intersection-element="loadMore" />
</div> </div>
</div> </div>
</div> </div>
@ -93,4 +115,5 @@ export default {
</template> </template>
<style> <style>
</style> </style>

View file

@ -13,14 +13,18 @@ export default defineConfig(({command, mode, ssrBuild}) => {
} }
}, },
}; };
if (command === 'serve') { if (command === 'serve' && mode !== 'developement-external') {
ret.define = { ret.define = {
"__API_URL__": JSON.stringify("http://localhost:3000"), "__API_URL__": JSON.stringify("http://localhost:3000"),
}; };
} else { } else if (mode === 'embedded') {
ret.define = { ret.define = {
"__API_URL__": JSON.stringify("/"), "__API_URL__": JSON.stringify("/"),
}; };
} else {
ret.define = {
"__API_URL__": JSON.stringify("<your API URL>"),
};
} }
return ret; return ret;
}) })