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",
"request": "launch",
"mode": "auto",
"buildFlags": "",
"buildFlags": "-tags webui",
"program": "./cmd/webapi",
"args": [
"--db-filename", "/home/marco/wasa/wasadata/wasaphoto.db", "--data-path", "/home/marco/wasa/wasadata/data"
]
//"args": [
// "--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!*
*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
* A backend written in the Go language
* 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")
// 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
logger.Println("initializing database support")
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")
return fmt.Errorf("creating the API server instance: %w", err)
}
router := apirouter.Handler()
router, err = registerWebUI(router)

View file

@ -4,10 +4,11 @@ package main
import (
"fmt"
"github.com/notherealmarco/WASAPhoto/webui"
"io/fs"
"net/http"
"strings"
"github.com/notherealmarco/WASAPhoto/webui"
)
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/") {
http.StripPrefix("/dashboard/", http.FileServer(http.FS(distDirectory))).ServeHTTP(w, r)
return
} else if r.RequestURI == "/" {
// Redirect to dashboard
http.Redirect(w, r, "/dashboard/", http.StatusTemporaryRedirect)
return
}
hdl.ServeHTTP(w, r)
}), nil

View file

@ -1,4 +1,4 @@
openapi: 3.0.3
openapi: 3.0.2
info:
title: WASAPhoto API
description: |-
@ -210,7 +210,7 @@ paths:
$ref: "#/components/schemas/generic_response"
example:
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.
content:
application/json:
@ -920,6 +920,7 @@ components:
maxLength: 36
description: The user ID.
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"
name_object:
type: object
@ -1080,9 +1081,9 @@ components:
comment:
$ref: "#/components/schemas/comment"
comment:
minLength: 5
minLength: 1
maxLength: 255
pattern: ".*" #everything except newlines ^[*]{5, 255}$
pattern: "^(.){1,255}$" # everything except newlines
type: string
example: "What a lovely picture! 😊"
description: The comment's text
@ -1092,7 +1093,7 @@ components:
format: binary
minLength: 1
maxLength: 10485760 # 10 MB
pattern: "((.|\n)*)" # todo: review. Btw this means "any string"
pattern: "((.|\n)*)" # this accepts everything
generic_response:
type: object

View file

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

View file

@ -8,6 +8,8 @@ import (
"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 {
token string
}
@ -16,6 +18,8 @@ func (b *BearerAuth) GetType() string {
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) {
if header == "" {
return nil, errors.New("missing authorization header")
@ -29,10 +33,12 @@ func BuildBearer(header string) (*BearerAuth, error) {
return &BearerAuth{token: header[7:]}, nil
}
// Returns the user ID of the user that is currently logged in
func (b *BearerAuth) GetUserID() string {
return b.token
}
// Checks if the token is valid
func (b *BearerAuth) Authorized(db database.AppDatabase) (reqcontext.AuthStatus, error) {
// this is the way we manage authorization, the bearer token is the user id
state, err := db.UserExists(b.token)
@ -47,6 +53,7 @@ func (b *BearerAuth) Authorized(db database.AppDatabase) (reqcontext.AuthStatus,
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) {
// 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 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)
if err != nil {
@ -68,5 +76,6 @@ func (b *BearerAuth) UserAuthorized(db database.AppDatabase, uid string) (reqcon
return auth, nil
}
// If the user is not the same as the one in the token, return FORBIDDEN
return reqcontext.FORBIDDEN, nil
}

View file

@ -10,6 +10,7 @@ import (
"github.com/sirupsen/logrus"
)
// BuildAuth returns an Authorization implementation for the currently logged in user
func BuildAuth(header string) (reqcontext.Authorization, error) {
auth, err := BuildBearer(header)
if err != nil {
@ -21,6 +22,8 @@ func BuildAuth(header string) (reqcontext.Authorization, error) {
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 {
auth, err := f(db, uid)
if err != nil {
@ -28,21 +31,25 @@ func SendAuthorizationError(f func(db database.AppDatabase, uid string) (reqcont
return false
}
if auth == reqcontext.UNAUTHORIZED {
// The token is not valid
helpers.SendStatus(http.StatusUnauthorized, w, "Unauthorized", l)
return false
}
if auth == reqcontext.FORBIDDEN {
// The user is not authorized for this action
helpers.SendStatus(http.StatusForbidden, w, "Forbidden", l)
return false
}
// requested user is not found -> 404 as the resource is 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)
return false
}
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 {
auth, err := f(db)
@ -53,6 +60,7 @@ func SendErrorIfNotLoggedIn(f func(db database.AppDatabase) (reqcontext.AuthStat
}
if auth == reqcontext.UNAUTHORIZED {
// The token is not valid
helpers.SendStatus(http.StatusUnauthorized, w, "Unauthorized", l)
return false
}

View file

@ -66,6 +66,7 @@ func (rt *_router) PutBan(w http.ResponseWriter, r *http.Request, ps httprouter.
return
}
// Execute the query
status, err := rt.db.BanUser(uid, banned)
if err != nil {
@ -83,6 +84,13 @@ func (rt *_router) PutBan(w http.ResponseWriter, r *http.Request, ps httprouter.
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)
}
@ -95,6 +103,7 @@ func (rt *_router) DeleteBan(w http.ResponseWriter, r *http.Request, ps httprout
return
}
// Execute the query
status, err := rt.db.UnbanUser(uid, banned)
if err != nil {

View file

@ -3,7 +3,6 @@ package api
import (
"encoding/json"
"net/http"
"regexp"
"strconv"
"github.com/julienschmidt/httprouter"
@ -56,6 +55,7 @@ func (rt *_router) GetComments(w http.ResponseWriter, r *http.Request, ps httpro
}
// send the response
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(comments)
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)
stat, err := regexp.Match(`^[*]{5, 255}$`, []byte(request_body.Comment))
if err != nil {
helpers.SendInternalError(err, "Error matching regex", w, rt.baseLogger)
return
}
if !stat {
helpers.SendBadRequest(w, "Invalid comment", rt.baseLogger)
if !helpers.MatchCommentOrBadRequest(request_body.Comment, w, rt.baseLogger) {
return
}

View file

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

View file

@ -10,6 +10,8 @@ import (
"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 {
err := json.NewDecoder(r).Decode(v)
@ -20,6 +22,8 @@ func DecodeJsonOrBadRequest(r io.Reader, w http.ResponseWriter, v interface{}, l
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 {
user_exists, err := db.UserExists(uid)
@ -36,6 +40,8 @@ func VerifyUserOrNotFound(db database.AppDatabase, uid string, w http.ResponseWr
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) {
w.WriteHeader(httpStatus)
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) {
w.WriteHeader(http.StatusNotFound)
err := json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
if err != nil {
l.WithError(err).Error("Error encoding json")
}
SendStatus(http.StatusNotFound, w, description, l)
}
// Sends a Bad Request error to the client
func SendBadRequest(w http.ResponseWriter, description string, l logrus.FieldLogger) {
w.WriteHeader(http.StatusBadRequest)
err := json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
if err != nil {
l.WithError(err).Error("Error encoding json")
}
SendStatus(http.StatusBadRequest, w, description, l)
}
// 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) {
w.WriteHeader(http.StatusBadRequest)
l.WithError(err).Error(description)
err = json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
if err != nil {
l.WithError(err).Error("Error encoding json")
}
SendBadRequest(w, description, l)
}
// 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) {
w.WriteHeader(http.StatusInternalServerError)
l.WithError(err).Error(description)
err = json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
if err != nil {
l.WithError(err).Error("Error encoding json")
}
SendStatus(http.StatusInternalServerError, w, description, l)
}
// Tries to roll back a transaction, if it fails it logs the error
func RollbackOrLogError(tx database.DBTransaction, l logrus.FieldLogger) {
err := tx.Rollback()
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 {
banned, err := db.IsBanned(uid, banner)
if err != nil {

View file

@ -6,10 +6,12 @@ import (
)
const (
DEFAULT_LIMIT = 15 // don't know if should be moved to config
DEFAULT_LIMIT = 30
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) {
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
import (
"github.com/julienschmidt/httprouter"
"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
// 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) {
/* Example of liveness check:
if err := rt.DB.Ping(); err != nil {
if err := rt.db.Ping(); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}*/
}
helpers.SendStatus(200, w, "Server is live!", rt.baseLogger)
}

View file

@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"github.com/julienschmidt/httprouter"
"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"
// todo: we should check if the body is a valid jpg image
if err = os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { // perms = 511
helpers.SendInternalError(err, "Error creating directory", w, rt.baseLogger)
return
}
file, err := os.Create(path)
/*file, err := os.Create(path)
if err != nil {
helpers.SendInternalError(err, "Error creating file", w, rt.baseLogger)
helpers.RollbackOrLogError(transaction, rt.baseLogger)
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.RollbackOrLogError(transaction, rt.baseLogger)
return
}
if err = file.Close(); err != nil {
/*if err = file.Close(); err != nil {
helpers.SendInternalError(err, "Error closing file", w, rt.baseLogger)
helpers.RollbackOrLogError(transaction, rt.baseLogger)
}
}*/
err = transaction.Commit()
if err != nil {
helpers.SendInternalError(err, "Error committing transaction", w, rt.baseLogger)
//todo: should I roll back?
return
}
@ -139,7 +154,7 @@ func (rt *_router) DeletePhoto(w http.ResponseWriter, r *http.Request, ps httpro
if err != nil {
helpers.SendInternalError(err, "Error deleting photo from database", w, rt.baseLogger)
return
} // todo: maybe let's use a transaction also here
}
if !deleted {
helpers.SendNotFound(w, "Photo not found", rt.baseLogger)

View file

@ -25,19 +25,36 @@ func (rt *_router) PostSession(w http.ResponseWriter, r *http.Request, ps httpro
var request _reqbody
err := json.NewDecoder(r.Body).Decode(&request)
var uid string
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
if err != nil {
helpers.SendBadRequestError(err, "Bad request body", w, rt.baseLogger)
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")
// encode the response body
err = json.NewEncoder(w).Encode(_respbody{UID: uid})
if err != nil {

View file

@ -2,7 +2,6 @@ package api
import (
"net/http"
"regexp"
"github.com/julienschmidt/httprouter"
"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) {
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) {
return
}
// decode request body
var req structures.UserDetails
if !helpers.DecodeJsonOrBadRequest(r.Body, w, &req, rt.baseLogger) {
return
}
stat, err := regexp.Match(`^[a-zA-Z0-9_]{3,16}$`, []byte(req.Name))
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)
// check if the username is valid, and if it's not, send a bad request error
if !helpers.MatchUsernameOrBadRequest(req.Name, w, rt.baseLogger) {
return
}
status, err := rt.db.UpdateUsername(uid, req.Name)
// check if the username already exists
if status == database.ERR_EXISTS {
helpers.SendStatus(http.StatusConflict, w, "Username already exists", rt.baseLogger)
return
}
// handle any other database error
if err != nil {
helpers.SendInternalError(err, "Database error: UpdateUsername", w, rt.baseLogger)
return

View file

@ -11,9 +11,17 @@ const (
USER_NOT_FOUND = 3
)
// Authorization is the interface for an authorization provider
type Authorization interface {
// Returns the type of the authorization provider
GetType() string
// Returns the ID of the currently logged in user
GetUserID() string
// Checks if the token is valid
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)
}

View file

@ -79,8 +79,11 @@ type AppDatabase interface {
Ping() error
}
// DBTransaction is the interface for a generic database transaction
type DBTransaction interface {
// Commit commits the transaction
Commit() error
// Rollback rolls back the transaction
Rollback() error
}
@ -95,16 +98,17 @@ func New(db *sql.DB) (AppDatabase, error) {
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
//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)
if errors.Is(err, sql.ErrNoRows) {
// Database is empty, let's create the structure
sqlStmt := `CREATE TABLE "users" (
"uid" TEXT NOT NULL,
"name" TEXT NOT NULL UNIQUE,
PRIMARY KEY("uid")
)` // todo: one query is enough! We are we doing a query per table?
)`
_, err = db.Exec(sqlStmt)
if err != nil {
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 "u"."uid" = "c"."user"
ORDER BY "c"."date" DESC
LIMIT ?
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.
exists, err := db.photoExists(uid, photo)
if err != nil || !exists {
// The photo does not exist, or the user has been banned
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
}
// The user was not liking the photo
if rows == 0 {
return ERR_NOT_FOUND, nil
}

View file

@ -18,7 +18,6 @@ func (db *appdbimpl) PostPhoto(uid string) (DBTransaction, int64, error) {
err_rb := tx.Rollback()
// If rollback fails, we return the original error plus the rollback error
if err_rb != nil {
// todo: we are losing track of err_rb here
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()
// If rollback fails, we return the original error plus the rollback error
if err_rb != nil {
// todo: we are losing track of err_rb here
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
}
// 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) {
var cnt int64

View file

@ -5,8 +5,6 @@ import (
"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
func (db *appdbimpl) GetUserProfile(uid string, requesting_uid string) (QueryResult, *structures.UserProfile, error) {
// Get user info

View file

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

View file

@ -2,6 +2,7 @@ package database
import (
"database/sql"
"errors"
"github.com/gofrs/uuid"
"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
func (db *appdbimpl) GetUserID(name string) (string, error) {
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
}
// Create a new user
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()
if err != nil {
return "", err
}
// insert the new user into the database
_, err = db.c.Exec(`INSERT INTO "users" ("uid", "name") VALUES (?, ?)`, uid.String(), name)
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
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
}
_, err = db.c.Exec(`UPDATE "users" SET "name" = ? WHERE "uid" = ?`, name, uid)
if err != nil {
return ERR_INTERNAL, err
}
@ -95,7 +125,7 @@ func (db *appdbimpl) GetUserFollowers(uid string, requesting_uid string, start_i
AND "followed" = ?
LIMIT ?
OFFSET ?`, uid, requesting_uid, limit, start_index)
OFFSET ?`, requesting_uid, uid, limit, start_index)
followers, err := db.uidNameQuery(rows, err)
@ -107,7 +137,7 @@ func (db *appdbimpl) GetUserFollowers(uid string, requesting_uid string, start_i
}
// 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
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
}
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"
AND "follows"."followed" NOT IN (
@ -131,7 +161,7 @@ func (db *appdbimpl) GetUserFollowing(uid string, requesting_uid string, start_i
AND "follower" = ?
LIMIT ?
OFFSET ?`, uid, requesting_uid, offset, start_index)
OFFSET ?`, requesting_uid, uid, limit, start_index)
following, err := db.uidNameQuery(rows, err)

View file

@ -2,7 +2,7 @@ package db_errors
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 {
if err == nil {
return false
@ -10,6 +10,7 @@ func EmptySet(err error) bool {
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 {
if err == nil {
return false
@ -17,6 +18,7 @@ func UniqueViolation(err error) bool {
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 {
if err == nil {
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>
<html lang="en">
<head>
<title>Example app</title>
<title>WASAPhoto</title>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"/>
<meta name="viewport" content="width=device-width, initial-scale=1">

View file

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

View file

@ -1,83 +1,108 @@
<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>
<script>
export default {
data() {
props: ["user_id", "name", "date", "comments", "likes", "photo_id", "liked"],
data: function () {
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>
<template>
<!--<header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3 fs-6" href="#/">WASAPhoto</a>
<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">
<span class="navbar-toggler-icon"></span>
</button>
</header>-->
<!-- Modal to show error messages -->
<Modal ref="errModal" id="errorModal" :title="modalTitle">
{{ modalMsg }}
</Modal>
<div class="container-fluid">
<div class="row">
<!--<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
<div class="position-sticky pt-3 sidebar-sticky">
<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">
<main>
<!-- The view is rendered here -->
<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>
<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>
<RouterLink to="/" class="col-4 text-center">
<i class="bi bi-house text-dark" style="font-size: 2em"></i>
</RouterLink>
<RouterLink to="/search" class="col-4 text-center">
<i class="bi bi-search text-dark" style="font-size: 2em"></i>
</RouterLink>
<RouterLink :to="'/profile/' + my_id" class="col-4 text-center">
<i class="bi bi-person text-dark" style="font-size: 2em"></i>
</RouterLink>
<RouterLink to="/" class="col-4 text-center">
<i class="bi bi-house text-dark" style="font-size: 2em"></i>
</RouterLink>
<RouterLink to="/search" class="col-4 text-center">
<i class="bi bi-search text-dark" style="font-size: 2em"></i>
</RouterLink>
<RouterLink to="/profile/me" class="col-4 text-center">
<i class="bi bi-person text-dark" style="font-size: 2em"></i>
</RouterLink>
</nav>
</div>
</div>
</template>
<style>
/* Make the active navigation button a little bit bigger */
#global-nav a.router-link-active {
font-size: 1.2em
}

View file

@ -1,10 +1,12 @@
<script>
export default {
// The error message to display
props: ['msg']
}
</script>
<template>
<!-- This component renders an error message -->
<div class="alert alert-danger" role="alert">
{{ msg }}
</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>
<template>
<!-- This component renders a loading spinner if the loading prop is true -->
<div v-if="loading">
<div style="text-align: center">
<div class="spinner-grow" role="status">
@ -14,5 +15,3 @@ export default {
</div>
<div v-if="!loading"><slot /></div>
</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>
export default {
props: ["user_id", "name", "date", "comments", "likes", "photo_id", "liked", "my_id"],
data: function() {
props: ["user_id", "name", "date", "comments", "likes", "photo_id", "liked"],
data: function () {
return {
imageSrc: "",
errorMsg: null,
// Whether the image is loaded (disables the spinner)
imageReady: false,
// Likes and comments
post_liked: this.liked,
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: {
// 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() {
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_like_cnt++;
}).catch(error => {
console.log(error);
this.errorMsg = error.toString();
});
})
},
// Unlike the photo
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_like_cnt--;
}).catch(error => {
console.log(error);
this.errorMsg = error.toString();
});
})
},
},
created() {
this.$axios.get("/users/" + this.user_id + "/photos/" + this.photo_id, {
responseType: 'arraybuffer'
}).then(response => {
const img = document.createElement('img');
img.src = URL.createObjectURL(new Blob([response.data]));
img.classList.add("card-img-top");
this.$refs.imageContainer.appendChild(img);
});
},
}
// Fetch the image from the server and display it
this.$axios.get("/users/" + this.user_id + "/photos/" + this.photo_id, {
responseType: 'arraybuffer'
}).then(response => {
// Create an image element and append it to the container
const img = document.createElement('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>
<template>
<div class="card mb-5">
<!--<img v-auth-img="imageSrc" class="card-img-top" alt="Chicago Skyscrapers"/>-->
<div ref="imageContainer"></div>
<div class="card mb-5">
<!-- 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="row">
<!-- Username and date -->
<div class="col-10">
<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>
</div>
</div>
<!-- Comment and like buttons -->
<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 -->
<a><h5><i class="card-title bi bi-chat-right pe-1"></i></h5></a>
<h6 class="card-text d-flex align-items-end text-muted">{{ comments }}</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>
<div class="card-body d-flex justify-content-end" style="display: inline-flex">
<a @click="showHideComments">
<h5><i class="card-title bi bi-chat-right pe-1"></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>
<h5></h5>
</div>
</div>
</div>
</div>
<!--<ul class="list-group list-group-light list-group-small">
<li class="list-group-item px-4">Cras justo odio</li>
<li class="list-group-item px-4">Dapibus ac facilisis in</li>
<li class="list-group-item px-4">Vestibulum at eros</li>
</ul>-->
<!-- Comments section -->
<div v-if="comments_shown">
<div v-for="item of comments_data" class="row" v-bind:key="item.comment_id">
<div class="col-7 card-body border-top">
<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>
<ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg>
</template>
<style>
.like-icon:hover {
color: #ff0000;
}
.like-red {
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,121 +1,219 @@
<script>
export default {
props: ["user_id", "name", "followed", "banned", "my_id", "show_new_post"],
props: ["user_id", "name", "followed", "banned", "show_new_post"],
watch: {
banned: function(new_val, old_val) {
this.user_banned = new_val;
name: function (new_val, old_val) {
this.username = new_val
},
followed: function(new_val, old_val) {
this.user_followed = new_val;
banned: function (new_val, old_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() {
return {
errorMsg: "aaa",
user_followed: this.followed,
user_banned: this.banned,
myself: this.my_id == this.user_id,
data: function () {
return {
// User data
username: this.name,
user_followed: this.followed,
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_username_form: false,
// The new username
newUsername: "",
// The file to upload
upload_file: null,
}
},
methods: {
}
},
methods: {
// Logout the user
logout() {
this.$root.logout()
},
// Visit the user's profile
visit() {
this.$router.push({ path: "/profile/" + this.user_id });
},
follow() {
this.$axios.put("/users/" + this.user_id + "/followers/" + this.my_id)
.then(response => {
this.user_followed = true
this.$emit('updateInfo')
})
.catch(error => alert(error.toString()));
},
unfollow() {
this.$axios.delete("/users/" + this.user_id + "/followers/" + this.my_id)
.then(response => {
this.user_followed = false
this.$emit('updateInfo')
})
.catch(error => alert(error.toString()));
},
ban() {
this.$axios.put("/users/" + this.my_id + "/bans/" + this.user_id)
.then(response => {
this.user_banned = true
this.$emit('updateInfo')
})
.catch(error => alert(error.toString()));
},
unban() {
this.$axios.delete("/users/" + this.my_id + "/bans/" + this.user_id)
.then(response => {
this.user_banned = false
this.$emit('updateInfo')
})
.catch(error => alert(error.toString()));
},
// Follow the user
follow() {
this.$axios.put("/users/" + this.user_id + "/followers/" + this.$currentSession())
.then(response => {
if (response == null) return // the interceptors returns null if something goes bad
this.user_followed = true
this.$emit('updateInfo')
})
},
// Unfollow the user
unfollow() {
this.$axios.delete("/users/" + this.user_id + "/followers/" + this.$currentSession())
.then(response => {
if (response == null) return
this.user_followed = false
this.$emit('updateInfo')
})
},
// Ban the user
ban() {
this.$axios.put("/users/" + this.$currentSession() + "/bans/" + this.user_id)
.then(response => {
if (response == null) return
this.user_banned = true
this.$emit('updateInfo')
})
},
// 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) {
let files = e.target.files || e.dataTransfer.files;
if (!files.length) return;
this.upload_file = files[0];
if (!files.length) return
this.upload_file = files[0]
},
// Upload the file
submit_file() {
this.$axios.post("/users/" + this.my_id + "/photos", this.upload_file)
.then(response => {
this.show_post_form = false
this.$emit('updatePosts')
})
.catch(error => alert(error.toString()));
this.$axios.post("/users/" + this.$currentSession() + "/photos", this.upload_file)
.then(response => {
if (response == null) return
this.show_post_form = false
this.$emit('updatePosts')
})
},
// 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>
<template>
<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">
<div class="row">
<div class="col-10">
<div class="card-body h-100 d-flex align-items-center">
<a @click="visit"><h5 class="card-title mb-0">{{ name }}</h5></a>
</div>
</div>
<!-- Whether to show one or two rows -->
<div class="d-flex flex-column" v-bind:class="{
'col-12': (myself && show_new_post),
'col-sm-7': (myself && show_new_post),
'col-7': !(myself && show_new_post),
'align-items-end': !(myself && show_new_post),
'align-items-sm-end': (myself && show_new_post),
}">
<div class="col-2">
<div class="card-body d-flex justify-content-end">
<!-- Buttons -->
<div class="card-body 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="unban" type="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>
<button v-if="!user_banned" @click="ban" type="button"
class="btn btn-outline-danger me-2">Ban</button>
<button v-if="user_banned" @click="unban" type="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>
<!-- Users cannot follow or ban themselves -->
<div v-if="(myself && !show_new_post)">
<button disabled type="button" class="btn btn-secondary">Yourself</button>
</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 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">
<div class="card-body d-flex justify-content-end">
<!-- File input -->
<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>
<!-- 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>
</template>

View file

@ -2,21 +2,33 @@ import {createApp, reactive} from 'vue'
import App from './App.vue'
import router from './router'
import { axios, updateToken as axiosUpdate } from './services/axios.js';
import getCurrentSession from './services/authentication';
import ErrorMsg from './components/ErrorMsg.vue'
import LoadingSpinner from './components/LoadingSpinner.vue'
import PostCard from './components/PostCard.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 './assets/dashboard.css'
import './assets/main.css'
// Create the Vue SPA
const app = createApp(App)
app.config.globalProperties.$axios = axios;
app.config.globalProperties.$axiosUpdate = axiosUpdate;
app.config.globalProperties.$currentSession = getCurrentSession;
// Register the components
app.component("ErrorMsg", ErrorMsg);
app.component("LoadingSpinner", LoadingSpinner);
app.component("PostCard", PostCard);
app.component("UserCard", UserCard);
app.component("ProfileCounters", ProfileCounters);
app.component("Modal", Modal);
app.component("IntersectionObserver", IntersectionObserver);
app.use(router)
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 getCurrentSession from "./authentication";
const instance = axios.create({
baseURL: __API_URL__,
timeout: 1000 * 5
timeout: 1000 * 60
});
//axios.interceptors.request.use(function (config) {
@ -13,7 +14,7 @@ const instance = axios.create({
//});
const updateToken = () => {
instance.defaults.headers.common['Authorization'] = 'Bearer ' + sessionStorage.getItem('token');
instance.defaults.headers.common['Authorization'] = 'Bearer ' + getCurrentSession();
}
export {

View file

@ -1,56 +1,82 @@
<script>
export default {
data: function() {
data: function () {
return {
errormsg: null,
// Whether the content is loading
// to show the loading spinner
loading: false,
// Stream data from the server
stream_data: [],
// Whether the data has ended
// to stop loading more data with the infinite scroll
data_ended: false,
// Parameters to load data dynamically when scrolling
start_idx: 0,
limit: 1,
my_id: sessionStorage.getItem("token"),
// Shows the retry button
loadingError: false,
}
},
methods: {
// Reload the whole page content
// fetching it again from the server
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);
// 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();
},
// Requests data from the server asynchronously
async loadContent() {
this.loading = true;
this.errormsg = null;
try {
let response = await this.$axios.get("/stream?start_index=" + this.start_idx + "&limit=" + this.limit);
if (response.data.length == 0) this.data_ended = true;
else this.stream_data = this.stream_data.concat(response.data);
this.loading = false;
} catch (e) {
if (e.response.status == 401) {
this.$router.push({ path: "/login" });
}
this.errormsg = e.toString();
let response = await this.$axios.get("/stream?start_index=" + this.start_idx + "&limit=" + this.limit);
// 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
this.loadingError = true
return
}
// 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 = () => {
let bottomOfWindow = Math.max(window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop) + window.innerHeight === document.documentElement.offsetHeight
if (bottomOfWindow && !this.data_ended) {
this.start_idx += this.limit;
this.loadContent();
}
}
// Loads more data when the user scrolls down
// (this is called by the IntersectionObserver component)
loadMore() {
// Avoid loading more content if the data has ended
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() {
// 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.limit = Math.round(window.innerHeight / 450);
this.scroll();
this.loadContent();
// Start loading the content
this.refresh();
}
}
</script>
@ -62,35 +88,40 @@ export default {
<div class="col-xl-6 col-lg-9">
<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">
There's nothing here 😢
<br />Why don't you start following somebody? 👻
</div>
<div id="main-content" v-for="item of stream_data">
<PostCard :user_id="item.user_id"
:photo_id="item.photo_id"
:name="item.name"
:date="item.date"
:comments="item.comments"
:likes="item.likes"
:liked="item.liked"
:my_id="my_id" />
<!-- The stream -->
<div id="main-content" v-for="item of stream_data" v-bind:key="item.photo_id">
<!-- PostCard for each photo -->
<PostCard :user_id="item.user_id" :photo_id="item.photo_id" :name="item.name" :date="item.date"
:comments="item.comments" :likes="item.likes" :liked="item.liked" />
</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! 👻
</div>
<!-- The loading spinner -->
<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>
</template>
<style>
</style>

View file

@ -1,105 +1,120 @@
<script>
import LoadingSpinner from '../components/LoadingSpinner.vue';
export default {
data: function () {
return {
// The error message to display
errormsg: null,
// Loading spinner state
loading: false,
some_data: null,
// Form inputs
field_username: "",
rememberLogin: false,
};
},
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() {
this.loading = true;
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
this.$axiosUpdate();
// Send the login request
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 {
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) {
this.errormsg = e.toString();
else {
// Login failed, show the error message
this.errormsg = response.data["error"];
}
this.loading = false;
},
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();
//}
// Disable the loading spinner
this.loading = false;
},
},
mounted() {
this.refresh();
},
components: { LoadingSpinner }
}
</script>
<template>
<div class="vh-100 container py-5 h-100">
<div class="row d-flex justify-content-center align-items-center h-100">
<!--<div class="col-sm"><h2>* immagina un logo carino *</h2></div>-->
<div class="col-12 col-md-8 col-lg-6 col-xl-5">
<div class="card" style="border-radius: 1rem">
<div class="card-body p-4">
<!-- Login form centered in the page -->
<div class="vh-100 container py-5 h-100">
<div class="row d-flex justify-content-center align-items-center h-100">
<!--<div class="col-sm"><h2>* immagina un logo carino *</h2></div>-->
<div class="col-12 col-md-8 col-lg-6 col-xl-5">
<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>
<!-- Email input -->
<div class="form-floating mb-4">
<input v-model="field_username" type="email" id="formUsername" class="form-control" placeholder="name@example.com"/>
<label class="form-label" for="formUsername">Username</label>
</div>
<form>
<!-- Email input -->
<div class="form-floating mb-4">
<input v-model="field_username" type="email" id="formUsername" class="form-control"
placeholder="name@example.com" />
<label class="form-label" for="formUsername">Username</label>
</div>
<!-- Password input -->
<div class="form-floating mb-4">
<input disabled type="password" id="formPassword" class="form-control" placeholder="gattina12"/>
<label class="form-label" for="formPassword">Password</label>
</div>
<!-- Password input -->
<div class="form-floating mb-4">
<input style="display: none" disabled type="password" id="formPassword"
class="form-control" placeholder="gattina12" />
<label style="display: none" class="form-label" for="formPassword">Password</label>
</div>
<!-- 2 column grid layout for inline styling -->
<div class="row mb-4">
<div class="col d-flex justify-content-center">
<!-- Checkbox -->
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="form2Example31" checked />
<label class="form-check-label" for="form2Example31">Remember me</label>
</div>
</div>
</div>
<!-- 2 column grid layout for inline styling -->
<div class="row mb-4">
<div class="col d-flex justify-content-center">
<!-- Checkbox -->
<div class="form-check">
<input v-model="rememberLogin" class="form-check-input" type="checkbox" value=""
id="form2Example31" />
<label class="form-check-label" for="form2Example31">Remember me</label>
</div>
</div>
</div>
<!-- Submit button -->
<button style="width: 100%" type="button" class="btn btn-primary btn-block mb-4" @click="login">Sign in</button>
<ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg>
<LoadingSpinner :loading="loading" />
</form>
</div>
<!-- Submit button -->
<button style="width: 100%" type="button" class="btn btn-primary btn-block mb-4"
@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>
</template>
<style>
</style>

View file

@ -1,72 +1,105 @@
<script>
import IntersectionObserver from '../components/IntersectionObserver.vue';
export default {
data: function() {
data: function () {
return {
errormsg: null,
loading: false,
// The profile to show
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: [],
// Dynamic loading parameters
data_ended: false,
start_idx: 0,
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: {
async refresh() {
this.getMainData();
this.limit = Math.round(window.innerHeight / 450);
this.start_idx = 0;
this.data_ended = false;
this.stream_data = [];
this.loadContent();
if (this.$route.params.user_id == "me") {
// If the id is "me", show the current user's profile
this.requestedProfile = this.$currentSession()
}
else {
// 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() {
try {
let response = await this.$axios.get("/users/" + this.$route.params.user_id);
this.user_data = response.data;
} catch(e) {
this.errormsg = e.toString();
}
},
// Fetch photos from the server
async loadContent() {
this.loading = true;
this.errormsg = null;
try {
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;
else this.stream_data = this.stream_data.concat(response.data);
this.loading = false;
} catch (e) {
if (e.response.status == 401) { // todo: move from here
this.$router.push({ path: "/login" });
}
this.errormsg = e.toString();
}
let response = await this.$axios.get("/users/" + this.requestedProfile + "/photos" + "?start_index=" + this.start_idx + "&limit=" + this.limit)
if (response == null) return // 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.stream_data = this.stream_data.concat(response.data)
// Disable the loading spinner
this.loading = false
},
scroll () {
window.onscroll = () => {
let bottomOfWindow = Math.max(window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop) + window.innerHeight === document.documentElement.offsetHeight
if (bottomOfWindow && !this.data_ended) {
this.start_idx += this.limit;
this.loadContent();
}
}
// Load more photos when the user scrolls to the bottom of the page
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()
},
},
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>
@ -77,54 +110,42 @@ export default {
<div class="row justify-content-md-center">
<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"
:name = "user_data['name']"
:followed = "user_data['followed']"
:banned = "user_data['banned']"
:my_id = "my_id"
:show_new_post = "true"
@updateInfo = "getMainData"
@updatePosts = "refresh" />
<!-- Photos, followers and following counters -->
<ProfileCounters :user_data="user_data" />
<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">
<PostCard :user_id = "$route.params.user_id"
:photo_id = "item.photo_id"
:name = "user_data['name']"
:date = "item.date"
:comments = "item.comments"
:likes = "item.likes"
:liked = "item.liked"
:my_id = "my_id" />
<!-- Photos -->
<div id="main-content" v-for="item of stream_data" v-bind:key="item.photo_id">
<!-- PostCard for the photo -->
<PostCard :user_id="requestedProfile" :photo_id="item.photo_id" :name="user_data['name']"
:date="item.date" :comments="item.comments" :likes="item.likes" :liked="item.liked" />
</div>
<!-- Message when the end is reached -->
<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>
<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>
</template>
<style>
</style>

View file

@ -1,62 +1,79 @@
<script>
// import getCurrentSession from '../services/authentication'; todo: can be removed
export default {
data: function() {
data: function () {
return {
// The error message to display
errormsg: null,
loading: false,
stream_data: [],
data_ended: false,
start_idx: 0,
// Search results
streamData: [],
// Dynamic loading
dataEnded: false,
startIdx: 0,
limit: 1,
field_username: "",
my_id: sessionStorage.getItem("token"),
// Search input
fieldUsername: "",
}
},
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.start_idx = 0;
this.data_ended = false;
this.stream_data = [];
// Reset the parameters and the data
this.startIdx = 0;
this.dataEnded = false;
this.streamData = [];
// Fetch the first batch of results
this.loadContent();
},
// Fetch the search results from the server
async loadContent() {
this.loading = true;
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.loading = false;
return;
}
try {
let response = await this.$axios.get("/users?query=" + this.field_username + "&start_index=" + this.start_idx + "&limit=" + this.limit);
if (response.data.length == 0) this.data_ended = true;
else this.stream_data = this.stream_data.concat(response.data);
this.loading = false;
} catch (e) {
this.errormsg = e.toString();
if (e.response.status == 401) {
this.$router.push({ path: "/login" });
}
// Fetch the results from the server
let response = await this.$axios.get("/users?query=" + this.fieldUsername + "&start_index=" + this.startIdx + "&limit=" + this.limit);
// 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 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 = () => {
let bottomOfWindow = Math.max(window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop) + window.innerHeight === document.documentElement.offsetHeight
if (bottomOfWindow && !this.data_ended) {
this.start_idx += this.limit;
this.loadContent();
}
}
// Load a new batch of results when the user scrolls to the bottom of the page
loadMore() {
if (this.loading || this.dataEnded) return
this.startIdx += this.limit
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>
@ -68,23 +85,28 @@ export default {
<h3 class="card-title border-bottom mb-4 pb-2 text-center">WASASearch</h3>
<!-- Error message -->
<ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg>
<div class="form-floating mb-4">
<input v-model="field_username" @input="refresh" id="formUsername" class="form-control" placeholder="name@example.com"/>
<label class="form-label" for="formUsername">Search by username</label>
</div>
<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" />
<!-- Search form -->
<div class="form-floating mb-4">
<input v-model="fieldUsername" @input="query" id="formUsername" class="form-control"
placeholder="name@example.com" />
<label class="form-label" for="formUsername">Search by username</label>
</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 />
<!-- The IntersectionObserver for dynamic loading -->
<IntersectionObserver sentinal-name="load-more-search" @on-intersection-element="loadMore" />
</div>
</div>
</div>
@ -93,4 +115,5 @@ export default {
</template>
<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 = {
"__API_URL__": JSON.stringify("http://localhost:3000"),
};
} else {
} else if (mode === 'embedded') {
ret.define = {
"__API_URL__": JSON.stringify("/"),
};
} else {
ret.define = {
"__API_URL__": JSON.stringify("<your API URL>"),
};
}
return ret;
})