1 Commits

Author SHA1 Message Date
tpeetz 4c918f01db kontor-vue with tutorial implementation 2026-04-12 16:23:10 +02:00
124 changed files with 5100 additions and 4619 deletions
-19
View File
@@ -1,19 +0,0 @@
name: Gitea Actions Demo
run-name: ${{ gitea.actor }} ist testing out Gitea Actions
on: [push]
jobs:
Explore-Gitea-Actions:
runs-on: inky
steps:
- run: echo "The job was automatically triggered by a ${{ gitea.event_name }} event."
- run: echo "This job is now running on a ${{ runner.os }} server hosted by Gitea!"
- run: echo "The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
- name: Check out repository code
uses: actions/checkout@v4
- run: echo "The ${{ gitea.repository }} repository has been cloned to the runner."
- run: echo "The workflow is now ready to test your code on the runner."
- name: List files in the directory
run: |
ls ${{ gitea.workspace }}
- run: echo "The job's status is ${{ job.status }}."
@@ -1,5 +1,5 @@
### STAGE 1: Build ### ### STAGE 1: Build ###
FROM docker.io/node:22-alpine AS build FROM node:22.15-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm install RUN npm install
@@ -7,9 +7,8 @@ COPY . .
RUN npm run build RUN npm run build
### STAGE 2: Run ### ### STAGE 2: Run ###
FROM docker.io/library/nginx:stable-alpine FROM nginx:1.17.1-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/kontor-angular/browser /usr/share/nginx/html COPY --from=build /app/dist/kontor-angular/browser /usr/share/nginx/html
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
-1
View File
@@ -1,5 +1,4 @@
server { server {
listen 8800;
# Root-Verzeichnis für den Server setzen (wir kopieren unsere Anwendung hierher) # Root-Verzeichnis für den Server setzen (wir kopieren unsere Anwendung hierher)
root /usr/share/nginx/html; root /usr/share/nginx/html;
+2 -22
View File
@@ -1,32 +1,12 @@
"""
add router for different parts (like comics, tysc, media)
"""
from fastapi import APIRouter from fastapi import APIRouter
from src.apis.version1 import ( from src.apis.version1 import comic, mediaactor, mediafile, mediaactorfile, tysc, admin, user
comic,
mediaactor,
mediafile,
mediaactorfile,
sport,
player,
team,
fieldposition,
vendor,
admin,
user,
)
api_router = APIRouter(prefix="/api") api_router = APIRouter(prefix="/api")
api_router.include_router(comic.router, prefix="/comics", tags=["comics"]) api_router.include_router(comic.router, prefix="/comics", tags=["comics"])
api_router.include_router(mediafile.router, prefix="/media", tags=["media"]) api_router.include_router(mediafile.router, prefix="/media", tags=["media"])
api_router.include_router(mediaactor.router, prefix="/media", tags=["media"]) api_router.include_router(mediaactor.router, prefix="/media", tags=["media"])
api_router.include_router(mediaactorfile.router, prefix="/media", tags=["media"]) api_router.include_router(mediaactorfile.router, prefix="/media", tags=["media"])
api_router.include_router(sport.router, prefix="/tysc", tags=["tysc"]) api_router.include_router(tysc.router, prefix="/tysc", tags=["tysc"])
api_router.include_router(player.router, prefix="/tysc", tags=["tysc"])
api_router.include_router(team.router, prefix="/tysc", tags=["tysc"])
api_router.include_router(fieldposition.router, prefix="/tysc", tags=["tysc"])
api_router.include_router(vendor.router, prefix="/tysc", tags=["tysc"])
api_router.include_router(admin.router, prefix="/login", tags=["login"]) api_router.include_router(admin.router, prefix="/login", tags=["login"])
api_router.include_router(user.router, prefix="/user", tags=["user"]) api_router.include_router(user.router, prefix="/user", tags=["user"])
+3 -5
View File
@@ -5,8 +5,7 @@ from fastapi import APIRouter, Body, HTTPException, status, Depends, Response
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from src.core.config import settings from src.core.config import settings
from src.core.log_conf import logger from src.core.security import create_access_token, authenticate_user, get_current_active_user
from src.core.security import create_access_token, authenticate_user_by_email, authenticate_user_by_username, get_current_active_user
from src.db.models.admin import Profile from src.db.models.admin import Profile
from src.schema.admin import Token, ProfileModel from src.schema.admin import Token, ProfileModel
from src.webapps.auth.forms import LoginForm from src.webapps.auth.forms import LoginForm
@@ -16,8 +15,7 @@ router = APIRouter()
@router.post("/token") @router.post("/token")
def login_for_access_token(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> Token: def login_for_access_token(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> Token:
user = authenticate_user_by_username(form_data.username, form_data.password) user = authenticate_user(form_data.username, form_data.password)
logger.info(f"Request /token: login with {form_data.username}")
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -33,7 +31,7 @@ def login_for_access_token(form_data: Annotated[OAuth2PasswordRequestForm, Depen
# @router.post("/token-cookie", response_model=Token) # @router.post("/token-cookie", response_model=Token)
def login_for_token_cookie(response: Response, form_data: LoginForm = Depends()): def login_for_token_cookie(response: Response, form_data: LoginForm = Depends()):
user = authenticate_user_by_email(form_data.username, form_data.password) # type: ignore user = authenticate_user(form_data.username, form_data.password) # type: ignore
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1,19 +0,0 @@
from typing import List
from fastapi import APIRouter
from src.db.models.tysc import FieldPosition
from src.db.session import SessionDep
from src.schema.tysc.fieldposition import FieldPositionResponse, to_response
router = APIRouter()
@router.get("/positions")
def get_all_teams(db: SessionDep) -> List[FieldPositionResponse]:
results: list[FieldPositionResponse] = []
sports = db.query(FieldPosition).all()
for sport in sports:
response = to_response(sport)
results.append(response)
return results
+4 -19
View File
@@ -1,12 +1,11 @@
from datetime import timedelta from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer, SecurityScopes
from pydantic import BaseModel from pydantic import BaseModel
from typing import Annotated
from src.core.config import settings from src.core.config import settings
from src.core.log_conf import logger from src.core.log_conf import logger
from src.core.security import authenticate_user_by_email, authenticate_user_by_username, create_access_token from src.core.security import authenticate_user, create_access_token
from src.schema.admin import Token from src.schema.admin import Token
login_router = APIRouter() login_router = APIRouter()
@@ -26,7 +25,7 @@ class LoginRequest(BaseModel):
) )
def login(request: LoginRequest) -> Token: def login(request: LoginRequest) -> Token:
logger.info(f"login with {request.email}") logger.info(f"login with {request.email}")
user = authenticate_user_by_email(request.email, request.password) user = authenticate_user(request.email, request.password)
scopes = ["admin", "read"] scopes = ["admin", "read"]
if not user: if not user:
raise HTTPException( raise HTTPException(
@@ -40,17 +39,3 @@ def login(request: LoginRequest) -> Token:
expires_delta=access_token_expires, expires_delta=access_token_expires,
) )
return Token(access_token=access_token, token_type="bearer") return Token(access_token=access_token, token_type="bearer")
@login_router.post("/token", tags=["login"], summary="Login for access token")
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
user = authenticate_user_by_username(form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=400, detail="Incorrect username or password")
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.user_name, "scope": " ".join(form_data.scopes)},
expires_delta=access_token_expires,
)
return Token(access_token=access_token, token_type="bearer")
-19
View File
@@ -1,19 +0,0 @@
from typing import List
from fastapi import APIRouter
from src.db.models.tysc import Player
from src.db.session import SessionDep
from src.schema.tysc.player import PlayerResponse, to_response
router = APIRouter()
@router.get("/players")
def get_all_players(db: SessionDep) -> List[PlayerResponse]:
results: List[PlayerResponse] = []
players = db.query(Player).all()
for player in players:
response = to_response(player)
results.append(response)
return results
-19
View File
@@ -1,19 +0,0 @@
from typing import List
from fastapi import APIRouter
from src.db.models.tysc import Team
from src.db.session import SessionDep
from src.schema.tysc.team import TeamResponse, to_response
router = APIRouter()
@router.get("/teams")
def get_all_teams(db: SessionDep) -> List[TeamResponse]:
results: list[TeamResponse] = []
sports = db.query(Team).all()
for sport in sports:
response = to_response(sport)
results.append(response)
return results
@@ -2,7 +2,7 @@ from typing import List
from fastapi import APIRouter from fastapi import APIRouter
from src.db.session import SessionDep from src.db.session import SessionDep
from src.schema.tysc.sport import SportResponse, to_response from src.schema.tysc.sport import SportResponse
from src.db.models.tysc import Sport from src.db.models.tysc import Sport
router = APIRouter() router = APIRouter()
@@ -12,6 +12,5 @@ def get_all_sports(db: SessionDep) -> List[SportResponse]:
results: list[SportResponse] = [] results: list[SportResponse] = []
sports = db.query(Sport).all() sports = db.query(Sport).all()
for sport in sports: for sport in sports:
response = to_response(sport) results.append(SportResponse(id=sport.id, name=sport.name))
results.append(response)
return results return results
-22
View File
@@ -1,22 +0,0 @@
from typing import List
from fastapi import APIRouter
from src.db.models.tysc import Vendor
from src.db.session import SessionDep
from src.schema.tysc.vendor import VendorResponse, to_response
router = APIRouter()
@router.get("/vendors")
def get_all_vendors(db: SessionDep) -> List[VendorResponse]:
"""
retrieve all vendors as json response.
"""
results: list[VendorResponse] = []
vendors = db.query(Vendor).all()
for vendor in vendors:
response = to_response(vendor)
results.append(response)
return results
+37 -57
View File
@@ -13,47 +13,48 @@ from pydantic import ValidationError
from src.core.config import settings from src.core.config import settings
from src.core.log_conf import logger from src.core.log_conf import logger
from src.db.models.admin import Profile from src.db.models.admin import Profile
from src.db.repository.admin import get_profile_by_username, get_profile_by_email, is_database_empty from src.db.repository.admin import get_profile, is_database_empty
from src.db.session import SessionLocal from src.db.session import SessionLocal
from src.schema.admin import ProfileModel, TokenData from src.schema.admin import ProfileModel, TokenData
oauth2_scheme = OAuth2PasswordBearer( oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="/token", tokenUrl="/api/login/token",
scopes={"me": "read", "admin": "read", "ROLE_ADMIN": "admin", "ROLE_MEDIA": "media", "ROLE_USER": "user"}, scopes={"me": "read", "admin": "read"},
) )
# class OAuth2PasswordBearerWithCookie(OAuth2): class OAuth2PasswordBearerWithCookie(OAuth2):
# def __init__( def __init__(
# self, self,
# tokenUrl: str, tokenUrl: str,
# scheme_name: Optional[str] = None, scheme_name: Optional[str] = None,
# scopes: Optional[Dict[str, str]] = None, scopes: Optional[Dict[str, str]] = None,
# auto_error: bool = True, auto_error: bool = True,
# ): ):
# if not scopes: if not scopes:
# scopes = {} scopes = {}
# flows = OAuthFlowsModel(password={"tokenUrl": tokenUrl, "scopes": scopes}) # type: ignore flows = OAuthFlowsModel(password={"tokenUrl": tokenUrl, "scopes": scopes}) # type: ignore
# super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error) super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error)
# async def __call__(self, request: Request) -> Optional[str]: async def __call__(self, request: Request) -> Optional[str]:
# authorization: str = request.cookies.get("access_token") # changed to accept access token from httpOnly Cookie authorization: str = request.cookies.get("access_token") # changed to accept access token from httpOnly Cookie
# scheme, param = get_authorization_scheme_param(authorization) scheme, param = get_authorization_scheme_param(authorization)
# if not authorization or scheme.lower() != "bearer": if not authorization or scheme.lower() != "bearer":
# if self.auto_error: if self.auto_error:
# raise HTTPException( raise HTTPException(
# status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
# detail="Not authenticated", detail="Not authenticated",
# headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
# ) )
# else: else:
# return None return None
# return param return param
def authenticate_user_by_email(email: str, password: str) -> Optional[Profile]:
def authenticate_user(username: str, password: str) -> Optional[Profile]:
with SessionLocal() as db: with SessionLocal() as db:
user = get_profile_by_email(email=email, db=db) user = get_profile(username=username, db=db)
logger.debug(user) logger.debug(user)
if not user: if not user:
if is_database_empty(db): if is_database_empty(db):
@@ -64,26 +65,7 @@ def authenticate_user_by_email(email: str, password: str) -> Optional[Profile]:
return None return None
else: else:
if bcrypt.checkpw(password.encode(), user.password.encode()): if bcrypt.checkpw(password.encode(), user.password.encode()):
logger.info("User successful authenticated") print("User successful authenticated")
else:
logger.info("Authentication failed!")
return user
def authenticate_user_by_username(username: str, password: str) -> Optional[Profile]:
with SessionLocal() as db:
user = get_profile_by_username(username=username, db=db)
logger.debug(user)
if not user:
if is_database_empty(db):
logger.info("database is empty, use temporary access")
user = Profile()
user.email = "init_user@thpeetz.de"
return user
return None
else:
if bcrypt.checkpw(password.encode(), user.password.encode()):
logger.info("User successful authenticated")
else: else:
logger.info("Authentication failed!") logger.info("Authentication failed!")
return user return user
@@ -121,19 +103,17 @@ async def get_current_user(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
) )
username: str = payload.get("sub") # type: ignore username: str = payload.get("sub") # type: ignore
logger.info("username/email extracted is %s", username) logger.info("username/email extracted is ", username)
if username is None: if username is None:
raise credentials_exception raise credentials_exception
scope: str = payload.get("scope", "") scope: str = payload.get("scope", "")
token_scopes: List[str] = scope.split(" ") token_scopes: List[str] = scope.split(" ")
token_data = TokenData(scopes=token_scopes, username=username) token_data = TokenData(scopes=token_scopes, username=username)
except (JWTError, ValidationError): except (JWTError, ValidationError):
logger.info("Exception raised", exc_info=True)
raise credentials_exception raise credentials_exception
with SessionLocal() as db: with SessionLocal() as db:
user = get_profile_by_username(username=token_data.username, db=db) user = get_profile(username=token_data.username, db=db) # type: ignore
if user is None: if user is None:
logger.info("user not found")
raise credentials_exception raise credentials_exception
for scope in security_scopes.scopes: for scope in security_scopes.scopes:
if scope not in token_scopes: if scope not in token_scopes:
@@ -148,7 +128,7 @@ async def get_current_user(
async def get_current_active_user( async def get_current_active_user(
current_user: Annotated[Profile, Security(get_current_user, scopes=["me"])], current_user: Annotated[Profile, Security(get_current_user, scopes=["me"])],
) -> ProfileModel: ) -> ProfileModel:
if not current_user.enabled: if not current_user.enabled: # type: ignore
raise HTTPException(status_code=400, detail="Inactive user") raise HTTPException(status_code=400, detail="Inactive user")
user_model = ProfileModel( user_model = ProfileModel(
username=current_user.user_name, username=current_user.user_name,
@@ -170,13 +150,13 @@ def get_current_user_from_token(token: str = Depends(oauth2_scheme)):
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
) )
username: str = payload.get("sub") # type: ignore username: str = payload.get("sub") # type: ignore
logger.info("username/email extracted is %s", username) logger.info("username/email extracted is ", username)
if username is None: if username is None:
raise credentials_exception raise credentials_exception
except JWTError: except JWTError:
raise credentials_exception raise credentials_exception
with SessionLocal() as db: with SessionLocal() as db:
user = get_profile_by_email(email=username, db=db) user = get_profile(username=username, db=db)
if user is None: if user is None:
raise credentials_exception raise credentials_exception
return user return user
+3 -7
View File
@@ -1,16 +1,12 @@
from typing import Optional from typing import AnyStr, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.db.models.admin import Profile from src.db.models.admin import Profile
def get_profile_by_username(username: str, db: Session) -> Optional[Profile]: def get_profile(username: AnyStr, db: Session) -> Optional[Profile]:
profile = db.query(Profile).filter(Profile.user_name == username).first() profile = db.query(Profile).filter(Profile.email == username).first()
return profile
def get_profile_by_email(email: str, db: Session) -> Optional[Profile]:
profile = db.query(Profile).filter(Profile.email == email).first()
return profile return profile
def is_database_empty(db: Session) -> bool: def is_database_empty(db: Session) -> bool:
+1 -9
View File
@@ -1,23 +1,15 @@
"""
Schema definitions for Comics.
"""
from typing import Optional from typing import Optional
from pydantic import BaseModel, AnyUrl from pydantic import BaseModel, AnyUrl
from src.core.log_conf import logger
class ComicResponse(BaseModel): class ComicResponse(BaseModel):
"""
Pydantic model for returning Comic objects.
"""
id: str id: str
title: str title: str
completed: bool completed: bool
class ComicSchema(BaseModel): class ComicSchema(BaseModel):
"""
Pydantic model for uploading Comic object.
"""
id: str id: str
title: str title: str
weblink: Optional[AnyUrl] weblink: Optional[AnyUrl]
@@ -1,28 +0,0 @@
from datetime import datetime
from pydantic import BaseModel
from src.db.models.tysc import FieldPosition, Team
class FieldPositionResponse(BaseModel):
id: str
created_date: datetime
last_modified_date: datetime
version: int
name: str
short_name: str
sport_id: str
def to_response(fieldposition: FieldPosition) -> FieldPositionResponse:
response: FieldPositionResponse = FieldPositionResponse(
id=fieldposition.id,
created_date=fieldposition.created_date,
last_modified_date=fieldposition.last_modified_date,
version=fieldposition.version,
name=fieldposition.name,
short_name=fieldposition.short_name,
sport_id=fieldposition.sport_id
)
return response
-25
View File
@@ -1,25 +0,0 @@
from datetime import datetime
from pydantic import BaseModel
from src.db.models.tysc import Player
class PlayerResponse(BaseModel):
id: str
created_date: datetime
last_modified_date: datetime
version: int
first_name: str
last_name: str
def to_response(player: Player) -> PlayerResponse:
response: PlayerResponse = PlayerResponse(
id=player.id,
created_date=player.created_date,
last_modified_date=player.last_modified_date,
version=player.version,
first_name=player.first_name,
last_name=player.last_name
)
return response
-36
View File
@@ -1,36 +0,0 @@
from datetime import datetime
from pydantic import BaseModel
from src.db.models.tysc import Rooster
class RoosterResponse(BaseModel):
"""
Pydantic model for returning Rooster objects.
"""
id: str
created_date: datetime
last_modified_date: datetime
version: int
year: int
team_id: str
player_id: str
position_id: str
def to_reponse(rooster: Rooster) -> RoosterResponse:
"""
convert database object to response object (Pydantic).
"""
response: RoosterResponse = RoosterResponse(
id=rooster.id,
created_date=rooster.created_date,
last_modified_date=rooster.last_modified_date,
version=rooster.version,
year=rooster.year,
team_id=rooster.team_id,
player_id=rooster.player_id,
position_id=rooster.position_id
)
return response
+2 -18
View File
@@ -1,24 +1,8 @@
from datetime import datetime from typing import AnyStr
from pydantic import BaseModel from pydantic import BaseModel
from src.db.models.tysc import Sport
class SportResponse(BaseModel): class SportResponse(BaseModel):
id: str id: AnyStr
created_date: datetime
last_modified_date: datetime
version: int
name: str name: str
def to_response(sport: Sport) -> SportResponse:
response: SportResponse = SportResponse(
id=sport.id,
created_date=sport.created_date,
last_modified_date=sport.last_modified_date,
version=sport.version,
name=sport.name
)
return response
-28
View File
@@ -1,28 +0,0 @@
from datetime import datetime
from pydantic import BaseModel
from src.db.models.tysc import Team
class TeamResponse(BaseModel):
id: str
created_date: datetime
last_modified_date: datetime
version: int
name: str
short_name: str
sport_id: str
def to_response(team: Team) -> TeamResponse:
response: TeamResponse = TeamResponse(
id=team.id,
created_date=team.created_date,
last_modified_date=team.last_modified_date,
version=team.version,
name=team.name,
short_name=team.short_name,
sport_id=team.sport_id
)
return response
-32
View File
@@ -1,32 +0,0 @@
"""
class and function for json response objects for Vendor.
"""
from datetime import datetime
from pydantic import BaseModel
from src.db.models.tysc import Vendor
class VendorResponse(BaseModel):
"""
Pydantic model for Vendor reponse object.
"""
id: str
created_date: datetime
last_modified_date: datetime
version: int
name: str
def to_response(vendor: Vendor) -> VendorResponse:
"""
convert database object Vendor to response object VendorResponse.
"""
reponse: VendorResponse = VendorResponse(
id=vendor.id,
created_date=vendor.created_date,
last_modified_date=vendor.last_modified_date,
version=vendor.version,
name=vendor.name
)
return reponse
-12
View File
@@ -1,12 +0,0 @@
#
# https://help.github.com/articles/dealing-with-line-endings/
#
# Linux start script should use lf
/gradlew text eol=lf
# These are Windows script files and should use crlf
*.bat text eol=crlf
# Binary files should be left untouched
*.jar binary
-8
View File
@@ -1,8 +0,0 @@
# Ignore Gradle project-specific cache directory
.gradle
# Ignore Gradle build output directory
build
# Ignore Kotlin plugin data
.kotlin
-21
View File
@@ -1,21 +0,0 @@
/*
* This file was generated by the Gradle 'init' task.
*
* This is a general purpose Gradle build.
* Learn more about Gradle by exploring our Samples at https://docs.gradle.org/9.4.1/samples
*/
plugins {
id("base")
id("de.infolektuell.typst") version "0.8.0"
}
typst.version = "v0.14.2"
typst.sourceSets {
// Sources in src/main
val main by registering {
// The files to compile (without .typ extension) in src/main/typst
documents = listOf("kontor") // src/main/typst/document.typst
}
}
-5
View File
@@ -1,5 +0,0 @@
# This file was generated by the Gradle 'init' task.
# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties
org.gradle.configuration-cache=true
-2
View File
@@ -1,2 +0,0 @@
# This file was generated by the Gradle 'init' task.
# https://docs.gradle.org/current/userguide/version_catalogs.html#sec::toml-dependencies-format
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-248
View File
@@ -1,248 +0,0 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
-93
View File
@@ -1,93 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
-8
View File
@@ -1,8 +0,0 @@
/*
* This file was generated by the Gradle 'init' task.
*
* The settings file is used to specify which projects to include in your build.
* For more detailed information on multi-project builds, please refer to https://docs.gradle.org/9.4.1/userguide/multi_project_builds.html in the Gradle documentation.
*/
rootProject.name = "kontor-doc"
-25
View File
@@ -1,25 +0,0 @@
= Anforderungen der Domäne
== Systemfunktionen
=== Anwendungsfälle
=== Akteure
=== Zielgruppen
== Anforderungen
=== Anforderungen an externe Schnittstellen
=== Funktionale Anforderungen
=== Qualitätsanforderungen
=== Randbedingungen
=== Weitere Anforderungen
=== Wartungs- und Supportinformationen
== Verifikation
-99
View File
@@ -1,99 +0,0 @@
//#set page("a4")
#import "@preview/basic-report:0.4.0": *
#import "@preview/in-dexter:0.7.2": *
#import "@preview/pintorita:0.1.4"
#set text(fill: white)
#show raw.where(lang: "pintora"): it => pintorita.render(it.text)
#show: it => basic-report(
doc-category: "Entwicklungs- und Projekthandbuch",
doc-title: "Projekt kontor",
author: "Thomas Peetz",
//affiliation: "MouseTec, Entenhausen",
//logo: image("assets/aerospace-engineering.png", width: 2cm),
// <a href="https://www.flaticon.com/free-icons/aerospace" title="aerospace icons">Aerospace icons created by gravisio - Flaticon</a>
language: "de",
compact-mode: false,
it
)
= Allgemeines
#figure(
table(
columns: 4,
[Version], [Datum], [Autor], [Änderungsgrund / Bemerkungen],
[1.0.0], [16.05.2022], [Thomas Peetz], [Ersterstellung],
),
caption: [Dokumenthistorie],
)
== Zweck des Dokumentes
Das Entwicklungshandbuch beschreibt die Werkzeuge und die Vorgehensweise bei der Entwicklung
im Projekt kontor-spring und der Erstellung der Dokumentation.
== Verwendete Tools
=== Gitea
Für die Verwaltung des Sourcecode kommt Gitea #index[Gitea]@gitea zum Einsatz.
Mit Gitea werden auch die Projektaufgaben verwaltet.
Das Projekt und das dazugehörige Git Repository sind unter der Adresse @gitea-kontor zu finden.
= Erstellung der Dokumentation
Die Dokumentation des Projektes wird mit Asciidoctor #index[Asciidoctor]@asciidoctor geschrieben.
Die Dokumente erhalten ihre Namen nach dem jeweiligen Hauptdokument.
== Quellcode Verwaltung
Die Asciidoctor-Dateien haben die Endung `.adoc`.
== Buildsystem
Zur Erstellung der PDF-Dateien aus den Asciidoctor-Dateien wird das Buildsystem Gradle #index[Gradle]@gradle verwendet.
Die Dateien für die Dokumente liegen im Verzeichnis `src/docs/asciidoc`.
Der Gradle Build wird über die Datei `build.gradle` definiert.
#pagebreak()
= Einführung
== Zweck
== Stakeholder des Systems
== Systemumfang
=== Zielsetzung des Systems
#pagebreak()
#include "system.typ"
#include "domain.typ"
#include "projekt.typ"
= Verschiedenes
== Erreichbarkeiten
= Referenzen
#bibliography("reference.yaml", title: none, full: true)
= Glossar
= Index
#make-index(title: none, outlined: true, use-page-counter: true)
= Verzeichnisse
== Abbildungsverzeichnis
#outline(title: none, target: figure.where(kind: image))
== Tabellenverzeichnis
#outline(title: none, target: figure.where(kind: table))
-46
View File
@@ -1,46 +0,0 @@
= Projektbeschreibung
== Ausgangslage
//==== Rechtliche Vorgaben und Rahmenbedingungen
//=== Rahmenbedingungen
//==== Vorhandene Regelungen
== Projektziele
== Projektabgrenzung
//=== Voraussichtliche Kosten
//=== Projektrisiken
//==== Produktivität
//==== Finanzielle Risiken
//==== Akzeptanz
= Projektorganisation
== Projekt-Aufbauorganisation
== Rollendefinition
//==== Projektauftraggeber
//==== Projektausschuss
//==== Beratung / Qualitätssicherung
=== Projekteiter
=== Projektteam
=== Liste der Stakeholder
== Projektablauforganisation
=== Projekt-Phasen
== Erstellung der Projektdokumentation
-15
View File
@@ -1,15 +0,0 @@
asciidoctor:
type: web
url: http://asciidoctor.org
gitea:
type: web
url: http://www.gitea.org
gradle:
type: web
url: http://www.gradle.org
jenkins:
type: web
url: http://jenkins-ci.org
gitea-kontor:
type: web
url: https://gitea.thpeetz.de/kontor/kontor-spring
-336
View File
@@ -1,336 +0,0 @@
= Systemübersicht
== Systemkontext
== Systemarchitektur
== Systemschnittstellen
=== Realisierte Schnittstellen
=== Verwendete Schnittstellen
== Logisches Datenmodell
#figure(
kind: image,
```pintora
erDiagram
USER {
string id PK
datetime created_date
datetime last_modified_date
int version
string email
boolean enabled
string firstName
string lastName
string password
string token
boolean tokenExpired
string userName UNIQUE
}
USER ||--o{ AUTHORIZATION_MATRIX : "matrix"
ROLE {
string id PK
datetime created_date
datetime last_modified_date
int version
string name
}
ROLE ||--o{ AUTHORIZATION_MATRIX : "matrix"
AUTHORIZATION_MATRIX {
string id PK
datetime created_date
datetime last_modified_date
int version
string user_id FK
string role_id FK
}
```,
caption: [Benutzer ER-Diagramm]
)
#figure(
kind: image,
```pintora
erDiagram
comic {
string id PK
datetime created_date
datetime last_modified_date
int version
boolean completed
boolean currentOrder
string title
string publisher_id FK
}
comic ||--o{ comic_work : "1"
comic ||--o{ issue : "1"
comic ||--o{ volume : "1"
comic ||--o{ story_arc : "1"
comic ||--o{ trade_paperback : "1"
volume {
string id PK
datetime created_date
datetime last_modified_date
int version
string name
string comic_id FK
}
volume ||--o{ issue : "1"
issue {
string id PK
datetime created_date
datetime last_modified_date
int version
boolean in_stock
boolean is_read
string issue_number
string comic_id FK
string volume_id FK
}
publisher {
string id PK
datetime created_date
datetime last_modified_date
int version
string name
}
publisher ||--o{ comic : "1"
artist {
string id PK
datetime created_date
datetime last_modified_date
int version
string name
}
artist ||--o{ comic_work : "1"
story_arc {
string id PK
datetime created_date
datetime last_modified_date
int version
string name
string comic_id FK
}
trade_paperback {
string id PK
datetime created_date
datetime last_modified_date
int version
int issueStart
int issueEnd
string name
string comic_id FK
}
worktype {
string id PK
datetime created_date
datetime last_modified_date
int version
string name
}
worktype ||--o{ comic-work : "1"
comic_work {
string id PK
datetime created_date
datetime last_modified_date
int version
string artist_id FK
string comic_id FK
string worktype_id FK
}
```,
caption: [Comics ER-Diagramm]
)
#figure(
kind: image,
```pintora
erDiagram
sport {
string id PK
datetime created_date
datetime last_modified_date
int version
string name
}
team {
string id PK
datetime created_date
datetime last_modified_date
int version
string name
string short_name
string sport_id FK
}
field_position {
string id PK
datetime created_date
datetime last_modified_date
int version
string name
string short_name
string sport_id FK
}
rooster {
string id PK
datetime created_date
datetime last_modified_date
int version
int year
string player_id FK
string position_id FK
string team_id FK
}
player {
string id PK
datetime created_date
datetime last_modified_date
int version
string first_name
string last_name
}
vendor {
string id PK
datetime created_date
datetime last_modified_date
int version
string name
}
card_set {
string id PK
datetime created_date
datetime last_modified_date
int version
boolean insert_set
string name
boolean parallel_set
string vendor_id FK
}
card {
string id PK
datetime created_date
datetime last_modified_date
int version
int cardNumber
int year
string card_set FK
string rooster_id FK
string vendor_id FK
}
sport ||--o{ team : "1"
sport ||--o{ field_position : "1"
field_position ||--o{ rooster : "1"
player ||--o{ rooster : "1"
team ||--o{ rooster : "1"
vendor ||--o{ card : "1"
card_set ||--o{ card : "1"
rooster ||--o{ card : "1"
```,
caption: [TYSC ER-Diagramm]
)
#figure(
kind: image,
```pintora
erDiagram
book {
string id PK
datetime created_date
datetime last_modified_date
int version
string isbn UNIQUE
string title
int year
string publisher_id FK
}
bookshelf_publisher {
string id PK
datetime created_date
datetime last_modified_date
int version
string name UNIQUE
}
author {
string id PK
datetime created_date
datetime last_modified_date
int version
string first_name
string last_name
}
article {
string id PK
datetime created_date
datetime last_modified_date
int version
string title
}
article_author {
string id PK
datetime created_date
datetime last_modified_date
int version
string article_id FK
string author_id FK
}
book_author {
string id PK
datetime created_date
datetime last_modified_date
int version
string book_id FK
string author_id FK
}
bookshelf_publisher ||--o{ book : "1"
article ||--o{ article_author : "1"
author ||--o{ article_author : "1"
book ||--o{ book_author : "1"
author ||--o{ book_author : "1"
```,
caption: [Bookshelf ER-Diagramm]
)
#figure(
kind: image,
```pintora
erDiagram
mail {
string id PK
datetime created_date
datetime last_modified_date
int version
string subject
string content
datetime received_date
datetime sent_date
}
mail_account {
string id PK
datetime created_date
datetime last_modified_date
int version
string host
string password
int port
string protocol
boolean start_tls
string user_name
}
mail_address {
string id PK
datetime created_date
datetime last_modified_date
int version
string internet_address UNIQUE
string personal
string user_id FK
}
user ||--o{ mail_address : "1"
```,
caption: [Mail ER-Diagramm]
)
=== Einschränkungen
+15
View File
@@ -0,0 +1,15 @@
clean:
find . -name '*.py[co]' -delete
test:
python -m pytest \
-v \
--cov=kontor \
--cov-report=term \
--cov-report=html:coverage-report \
tests/
docker: clean
docker build -t kontor:latest .
+369
View File
@@ -0,0 +1,369 @@
"""
add actors
"""
import logging.config
import requests
import re
from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
from bs4 import BeautifulSoup
parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('--verbose', '-v', action='count', default=0)
args = parser.parse_args()
def get_logger(level: int) -> logging.Logger:
logging.config.dictConfig({
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'simple': {
'format': '[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s',
'datefmt': '%Y-%m-%d %H:%M:%S',
},
},
'handlers': {
'console': {
'class': logging.StreamHandler,
'level': logging.DEBUG,
'formatter': 'simple',
'stream': 'ext://sys.stdout'
},
},
'loggers': {
'urllib3.connectionpool': {
'level': 'WARNING',
'propagate': False,
},
'root': {
'level': 'DEBUG',
'handlers': ['console'],
},
},
})
logger = logging.getLogger(__file__)
if level is not None:
match level:
case 0:
logger.setLevel(logging.WARNING)
case 1:
logger.setLevel(logging.INFO)
case 2:
logger.setLevel(logging.DEBUG)
case _:
logger.setLevel(logging.CRITICAL)
return logger
if __name__ == '__main__':
log = get_logger(args.verbose)
log.warning('kontor.add_actors started')
log.debug('get all actors')
response = requests.get("http://127.0.0.1:8800/api/media/actors")
data = response.json()
actors = {}
for item in data:
actor = {}
actor['id'] = item['id']
actor['name'] = item['name']
actor['url'] = item['url']
actors[item['url']] = actor
log.debug(f'all actors: {actors}')
new_actor_list = [
{ 'name': 'Herschel Savage', 'url': 'https://ge.xhamster.com/pornstars/herschel-savage'},
{ 'name': 'Janey Robbins', 'url': 'https://ge.xhamster.com/pornstars/janey-robbins'},
{ 'name': 'Kimberly Carson', 'url': 'https://ge.xhamster.com/pornstars/kimberly-carson'},
{ 'name': 'Paul Thomas', 'url': 'https://ge.xhamster.com/pornstars/paul-thomas'},
{ 'name': 'Shauna Grant', 'url': 'https://ge.xhamster.com/pornstars/shauna-grant'},
{ 'name': 'Tara Aire', 'url': 'https://ge.xhamster.com/pornstars/tara-aire'},
{ 'name': 'Kari Milla', 'url': 'https://ge.xhamster.com/pornstars/kari-milla'},
{ 'name': 'Drago Gvozdik', 'url': 'https://ge.xhamster.com/pornstars/drago-gvozdik'},
{ 'name': 'Cheyne Collins', 'url': 'https://ge.xhamster.com/pornstars/cheyne-collins'},
{ 'name': 'Christian XXX', 'url': 'https://ge.xhamster.com/pornstars/christian-xxx'},
{ 'name': 'Derrick Pierce', 'url': 'https://ge.xhamster.com/pornstars/derrick-pierce'},
{ 'name': 'Holly Halston', 'url': 'https://ge.xhamster.com/pornstars/holly-halston'},
{ 'name': 'Holly West', 'url': 'https://ge.xhamster.com/pornstars/holly-west'},
{ 'name': 'Jarod Diamond', 'url': 'https://ge.xhamster.com/pornstars/jarod-diamond'},
{ 'name': 'Kristina Cross', 'url': 'https://ge.xhamster.com/pornstars/kristina-cross'},
{ 'name': 'Lee Stone', 'url': 'https://ge.xhamster.com/pornstars/lee-stone'},
{ 'name': 'Monica Mayhem', 'url': 'https://ge.xhamster.com/pornstars/monica-mayhem'},
{ 'name': 'Sienna West', 'url': 'https://ge.xhamster.com/pornstars/sienna-west'},
{ 'name': 'Aria Carson', 'url': 'https://ge.xhamster.com/pornstars/aria-carson'},
{ 'name': 'Britney Amber', 'url': 'https://ge.xhamster.com/pornstars/britney-amber'},
{ 'name': 'Kit Mercer', 'url': 'https://ge.xhamster.com/pornstars/kit-mercer'},
{ 'name': 'Riley Reyes', 'url': 'https://ge.xhamster.com/pornstars/riley-reyes'},
{ 'name': 'Ryan Keely', 'url': 'https://ge.xhamster.com/pornstars/ryan-keely'},
{ 'name': 'Alexandra Diamond', 'url': 'https://ge.xhamster.com/pornstars/alexandra-diamond'},
{ 'name': 'Amanda Twice', 'url': 'https://ge.xhamster.com/pornstars/amanda-twice'},
{ 'name': 'Choky Ice', 'url': 'https://ge.xhamster.com/pornstars/choky-ice'},
{ 'name': 'Cindy Cox', 'url': 'https://ge.xhamster.com/pornstars/cindy-cox'},
{ 'name': 'Kelly White', 'url': 'https://ge.xhamster.com/pornstars/kelly-white'},
{ 'name': 'Mike Foster', 'url': 'https://ge.xhamster.com/pornstars/mike-foster'},
{ 'name': 'Susie Sorrento', 'url': 'https://ge.xhamster.com/pornstars/susie-sorrento'},
{ 'name': 'Lara Sanchez', 'url': 'https://ge.xhamster.com/pornstars/lara-sanchez'},
{ 'name': 'Alicia Williams', 'url': 'https://ge.xhamster.com/pornstars/alicia-williams'},
{ 'name': 'Dirty Tina', 'url': 'https://ge.xhamster.com/pornstars/dirty-tina'},
{ 'name': 'Andrew Andretti', 'url': 'https://ge.xhamster.com/pornstars/andrew-andretti'},
{ 'name': 'Dick Nasty', 'url': 'https://ge.xhamster.com/pornstars/dick-nasty'},
{ 'name': 'Vicky Vette', 'url': 'https://ge.xhamster.com/pornstars/vicky-vette'},
{ 'name': 'Alex Gonz', 'url': 'https://ge.xhamster.com/pornstars/alex-gonz'},
{ 'name': 'Audrey Hollander', 'url': 'https://ge.xhamster.com/pornstars/audrey-hollander'},
{ 'name': 'Desiree Diamond', 'url': 'https://ge.xhamster.com/pornstars/desiree-diamond'},
{ 'name': 'Lexi Bardot', 'url': 'https://ge.xhamster.com/pornstars/lexi-bardot'},
{ 'name': 'Adam Ocelot', 'url': 'https://ge.xhamster.com/pornstars/adam-ocelot'},
{ 'name': 'Scarlett Hampton', 'url': 'https://ge.xhamster.com/pornstars/scarlett-hampton'},
{ 'name': 'Laetitia', 'url': 'https://ge.xhamster.com/pornstars/laetitia'},
{ 'name': 'Raffaela Anderson', 'url': 'https://ge.xhamster.com/pornstars/raffaela-anderson'},
{ 'name': 'Michael Swayze', 'url': 'https://ge.xhamster.com/pornstars/michael-swayze'},
{ 'name': 'Winter Jade', 'url': 'https://ge.xhamster.com/pornstars/winter-jade'},
{ 'name': 'Celine Noiret', 'url': 'https://ge.xhamster.com/pornstars/celine-noiret'},
{ 'name': 'Aurora Snow', 'url': 'https://ge.xhamster.com/pornstars/aurora-snow'},
{ 'name': 'Bree Brooks', 'url': 'https://ge.xhamster.com/pornstars/bree-brooks'},
{ 'name': 'Flick Shagwell', 'url': 'https://ge.xhamster.com/pornstars/flick-shagwell'},
{ 'name': 'Noname Jane', 'url': 'https://ge.xhamster.com/pornstars/noname-jane'},
{ 'name': 'Alex Magni', 'url': 'https://ge.xhamster.com/pornstars/alex-magni'},
{ 'name': 'Maeva Dream', 'url': 'https://ge.xhamster.com/pornstars/maeva-dream'},
{ 'name': 'Squirty Alice', 'url': 'https://ge.xhamster.com/pornstars/squirty-alice'},
{ 'name': 'Mandy Rhea', 'url': 'https://ge.xhamster.com/pornstars/mandy-rhea'},
{ 'name': 'Alessia Donati', 'url': 'https://ge.xhamster.com/pornstars/alessia-donati'},
{ 'name': 'Pierre DJ', 'url': 'https://ge.xhamster.com/pornstars/pierre-dj'},
{ 'name': 'Veronica Belli', 'url': 'https://ge.xhamster.com/pornstars/veronica-belli'},
{ 'name': 'Christiana Cinn', 'url': 'https://ge.xhamster.com/pornstars/christiana-cinn'},
{ 'name': 'Jasmine Jae', 'url': 'https://ge.xhamster.com/pornstars/jasmine-jae'},
{ 'name': 'Jay Smooth', 'url': 'https://ge.xhamster.com/pornstars/jay-smooth'},
{ 'name': 'Natalie Knight', 'url': 'https://ge.xhamster.com/pornstars/natalie-knight'},
{ 'name': 'Joshua Lewis', 'url': 'https://ge.xhamster.com/pornstars/joshua-lewis'},
{ 'name': 'Lauren Phillips', 'url': 'https://ge.xhamster.com/pornstars/lauren-phillips'},
{ 'name': 'Matt Cash', 'url': 'https://ge.xhamster.com/pornstars/matt-cash'},
{ 'name': 'Nickey Huntsman', 'url': 'https://ge.xhamster.com/pornstars/nickey-huntsman'},
{ 'name': 'Jade Sin', 'url': 'https://ge.xhamster.com/pornstars/jade-sin'},
{ 'name': 'Wein Lewis', 'url': 'https://ge.xhamster.com/pornstars/wein-lewis'},
{ 'name': 'Megan Murkovski', 'url': 'https://ge.xhamster.com/pornstars/megan-murkovski'},
{ 'name': 'Tara Wild', 'url': 'https://ge.xhamster.com/pornstars/tara-wild'},
{ 'name': 'Lana Roy', 'url': 'https://ge.xhamster.com/pornstars/lana-roy'},
{ 'name': 'Nick Moreno', 'url': 'https://ge.xhamster.com/pornstars/nick-moreno'},
{ 'name': 'Brad Armstrong', 'url': 'https://ge.xhamster.com/pornstars/brad-armstrong'},
{ 'name': 'Kaylani Lei', 'url': 'https://ge.xhamster.com/pornstars/kaylani-lei'},
{ 'name': 'Deborah Wells', 'url': 'https://ge.xhamster.com/pornstars/deborah-wells'},
{ 'name': 'Judith Ramirez', 'url': 'https://ge.xhamster.com/pornstars/judith-ramirez'},
{ 'name': 'Richard Langin', 'url': 'https://ge.xhamster.com/pornstars/richard-langin'},
{ 'name': 'Simona Valli', 'url': 'https://ge.xhamster.com/pornstars/simona-valli'},
{ 'name': 'Mandy Dee', 'url': 'https://ge.xhamster.com/pornstars/mandy-dee'},
{ 'name': 'Allison Wyte', 'url': 'https://ge.xhamster.com/pornstars/allison-wyte'},
{ 'name': 'Ben English', 'url': 'https://ge.xhamster.com/pornstars/ben-english'},
{ 'name': 'Chuck Martino', 'url': 'https://ge.xhamster.com/pornstars/chuck-martino'},
{ 'name': 'Erika Kole', 'url': 'https://ge.xhamster.com/pornstars/erika-kole'},
{ 'name': 'Katie Morgan', 'url': 'https://ge.xhamster.com/pornstars/katie-morgan'},
{ 'name': 'Misty Parks', 'url': 'https://ge.xhamster.com/pornstars/misty-parks'},
{ 'name': 'Mr. Pete', 'url': 'https://ge.xhamster.com/pornstars/mr-pete'},
{ 'name': 'Candie Luciani', 'url': 'https://ge.xhamster.com/pornstars/candie-luciani'},
{ 'name': 'Clara Mia', 'url': 'https://ge.xhamster.com/pornstars/clara-mia'},
{ 'name': 'Tommy Cabrio', 'url': 'https://ge.xhamster.com/pornstars/tommy-cabrio'},
{ 'name': 'Nathan Bronson', 'url': 'https://ge.xhamster.com/pornstars/nathan-bronson'},
{ 'name': 'Octavia Red', 'url': 'https://ge.xhamster.com/pornstars/octavia-red'},
{ 'name': 'Big George', 'url': 'https://ge.xhamster.com/pornstars/big-george'},
{ 'name': 'Cassy Young', 'url': 'https://ge.xhamster.com/pornstars/cassy-young'},
{ 'name': 'Egon Kowalski', 'url': 'https://ge.xhamster.com/pornstars/egon-kowalski'},
{ 'name': 'Mark Aurel', 'url': 'https://ge.xhamster.com/pornstars/mark-aurel'},
{ 'name': 'Samy Fox', 'url': 'https://ge.xhamster.com/pornstars/samy-fox'},
{ 'name': 'Valeria Jones', 'url': 'https://ge.xhamster.com/pornstars/valeria-jones'},
{ 'name': 'Dieter Von Stein', 'url': 'https://ge.xhamster.com/pornstars/dieter-von-stein'},
{ 'name': 'Donna Bell', 'url': 'https://ge.xhamster.com/pornstars/donna-bell'},
{ 'name': 'Molly Jane', 'url': 'https://ge.xhamster.com/pornstars/molly-jane'},
{ 'name': 'Mark Zane', 'url': 'https://ge.xhamster.com/pornstars/mark-zane'},
{ 'name': 'Titus Steel', 'url': 'https://ge.xhamster.com/pornstars/titus-steel'},
{ 'name': 'Mackenzie Mace', 'url': 'https://ge.xhamster.com/pornstars/mackenzie-mace'},
{ 'name': 'Christina Lang', 'url': 'https://ge.xhamster.com/pornstars/christina-lang'},
{ 'name': 'Jens Modena', 'url': 'https://ge.xhamster.com/pornstars/jens-modena'},
{ 'name': 'Karma Rosenberg', 'url': 'https://ge.xhamster.com/pornstars/karma-rosenberg'},
{ 'name': 'Dani Jensen', 'url': 'https://ge.xhamster.com/pornstars/dani-jensen'},
{ 'name': 'Crystal Ray', 'url': 'https://ge.xhamster.com/pornstars/crystal-ray'},
{ 'name': 'Andrea Szabo', 'url': 'https://ge.xhamster.com/pornstars/andrea-szabo'},
{ 'name': 'Desiree Barclay', 'url': 'https://ge.xhamster.com/pornstars/desiree-barclay'},
{ 'name': 'Jada Stevens', 'url': 'https://ge.xhamster.com/pornstars/jada-stevens'},
{ 'name': 'Syren Demer', 'url': 'https://ge.xhamster.com/pornstars/syren-demer'},
{ 'name': 'Ara Mix', 'url': 'https://ge.xhamster.com/pornstars/ara-mix'},
{ 'name': 'Redneck John', 'url': 'https://ge.xhamster.com/pornstars/redneck-john'},
{ 'name': 'Allysin Chaynes', 'url': 'https://ge.xhamster.com/pornstars/allysin-chaynes'},
{ 'name': 'Aspen Brock', 'url': 'https://ge.xhamster.com/pornstars/aspen-brock'},
{ 'name': 'Kaylynn', 'url': 'https://ge.xhamster.com/pornstars/kaylynn'},
{ 'name': 'Monica Cameron', 'url': 'https://ge.xhamster.com/pornstars/monica-cameron'},
{ 'name': 'Sheila Rossi', 'url': 'https://ge.xhamster.com/pornstars/sheila-rossi'},
{ 'name': 'Alex Ginger', 'url': 'https://ge.xhamster.com/pornstars/alex-ginger'},
{ 'name': 'Ally Style', 'url': 'https://ge.xhamster.com/pornstars/ally-style'},
{ 'name': 'Paola Mike', 'url': 'https://ge.xhamster.com/pornstars/paola-mike'},
{ 'name': 'Jeanette Littledove', 'url': 'https://ge.xhamster.com/pornstars/jeanette-littledove'},
{ 'name': 'Melissa Melendez', 'url': 'https://ge.xhamster.com/pornstars/melissa-melendez'},
{ 'name': 'Peter North', 'url': 'https://ge.xhamster.com/pornstars/peter-north'},
{ 'name': 'Siobhan Hunter', 'url': 'https://ge.xhamster.com/pornstars/siobhan-hunter'},
{ 'name': 'Tami White', 'url': 'https://ge.xhamster.com/pornstars/tami-white'},
{ 'name': 'Tracey Adams', 'url': 'https://ge.xhamster.com/pornstars/tracey-adams'},
{ 'name': 'Ashley Haze', 'url': 'https://ge.xhamster.com/pornstars/ashley-haze'},
{ 'name': 'Cailey Taylor', 'url': 'https://ge.xhamster.com/pornstars/cailey-taylor'},
{ 'name': 'Eve Laurence', 'url': 'https://ge.xhamster.com/pornstars/eve-laurence'},
{ 'name': 'Naudia Nyce', 'url': 'https://ge.xhamster.com/pornstars/naudia-nyce'},
{ 'name': 'Candy Apples', 'url': 'https://ge.xhamster.com/pornstars/candy-apples'},
{ 'name': 'Kate Rich', 'url': 'https://ge.xhamster.com/pornstars/kate-rich'},
{ 'name': 'Aniko Kaposi', 'url': 'https://ge.xhamster.com/pornstars/aniko-kaposi'},
{ 'name': 'Beatrice Poggi', 'url': 'https://ge.xhamster.com/pornstars/beatrice-poggi'},
{ 'name': 'Erika Bella', 'url': 'https://ge.xhamster.com/pornstars/erika-bella'},
{ 'name': 'Nikita Gross', 'url': 'https://ge.xhamster.com/pornstars/nikita-gross'},
{ 'name': 'Ursula Moore', 'url': 'https://ge.xhamster.com/pornstars/ursula-moore'},
{ 'name': 'Caty Kiss', 'url': 'https://ge.xhamster.com/pornstars/caty-kiss'},
{ 'name': 'Light Fairy', 'url': 'https://ge.xhamster.com/pornstars/light-fairy'},
{ 'name': 'Flame', 'url': 'https://ge.xhamster.com/pornstars/flame'},
{ 'name': 'Tiffany Tatum', 'url': 'https://ge.xhamster.com/pornstars/tiffany-tatum'},
{ 'name': 'Alyson Sykes', 'url': 'https://ge.xhamster.com/pornstars/alyson-sykes'},
{ 'name': 'Jenifer Stone', 'url': 'https://ge.xhamster.com/pornstars/jenifer-stone'},
{ 'name': 'Lucy Love', 'url': 'https://ge.xhamster.com/pornstars/lucy-love'},
{ 'name': 'Thomas Stone', 'url': 'https://ge.xhamster.com/pornstars/thomas-stone'},
{ 'name': 'Nikki Babe', 'url': 'https://ge.xhamster.com/pornstars/nikki-babe'},
{ 'name': 'Tyra Misoux', 'url': 'https://ge.xhamster.com/pornstars/tyra-misoux'},
{ 'name': 'Kenzie Reeves', 'url': 'https://ge.xhamster.com/pornstars/kenzie-reeves'},
{ 'name': 'Anissa Kate', 'url': 'https://ge.xhamster.com/pornstars/anissa-kate'},
{ 'name': 'Anna Polina', 'url': 'https://ge.xhamster.com/pornstars/anna-polina'},
{ 'name': 'Kimber Delice', 'url': 'https://ge.xhamster.com/pornstars/kimber-delice'},
{ 'name': 'Lucy Heart', 'url': 'https://ge.xhamster.com/pornstars/lucy-heart'},
{ 'name': 'John Strong', 'url': 'https://ge.xhamster.com/pornstars/john-strong'},
{ 'name': 'Markus Dupree', 'url': 'https://ge.xhamster.com/pornstars/markus-dupree'},
{ 'name': 'Mick Blue', 'url': 'https://ge.xhamster.com/pornstars/mick-blue'},
{ 'name': 'Natasha Nice', 'url': 'https://ge.xhamster.com/pornstars/natasha-nice'},
{ 'name': 'Lena Reif', 'url': 'https://ge.xhamster.com/pornstars/lena-reif'},
{ 'name': 'Sonia Paganini', 'url': 'https://ge.xhamster.com/pornstars/sonia-paganini'},
{ 'name': 'Demi Hawks', 'url': 'https://ge.xhamster.com/pornstars/demi-hawks'},
{ 'name': 'Juan El Caballo Loco', 'url': 'https://ge.xhamster.com/pornstars/juan-el-caballo-loco'},
{ 'name': 'Mike Mancini', 'url': 'https://ge.xhamster.com/pornstars/mike-mancini'},
{ 'name': 'Millie Morgan', 'url': 'https://ge.xhamster.com/pornstars/millie-morgan'},
{ 'name': 'Richard Allan', 'url': 'https://ge.xhamster.com/pornstars/richard-allan'},
{ 'name': 'Damon Dice', 'url': 'https://ge.xhamster.com/pornstars/damon-dice'},
{ 'name': 'Sera Ryder', 'url': 'https://ge.xhamster.com/pornstars/sera-ryder'},
{ 'name': 'Zoltan Kaabai', 'url': 'https://ge.xhamster.com/pornstars/zoltan-kabai'},
{ 'name': 'Cathy Heaven', 'url': 'https://ge.xhamster.com/pornstars/cathy-heaven'},
{ 'name': 'Coco Lovelock', 'url': 'https://ge.xhamster.com/pornstars/coco-lovelock'},
{ 'name': 'Percy Sires', 'url': 'https://ge.xhamster.com/pornstars/percy-sires'},
{ 'name': 'Meridian', 'url': 'https://ge.xhamster.com/pornstars/meridian'},
{ 'name': 'Pascal St. James', 'url': 'https://ge.xhamster.com/pornstars/pascal-st-james'},
{ 'name': 'Red Fox', 'url': 'https://ge.xhamster.com/pornstars/red-fox'},
{ 'name': 'Tony Art', 'url': 'https://ge.xhamster.com/pornstars/tony-art'},
{ 'name': 'Addison Lee', 'url': 'https://ge.xhamster.com/pornstars/addison-lee'},
{ 'name': 'Daria Glover', 'url': 'https://ge.xhamster.com/pornstars/daria-glover'},
{ 'name': 'Mandy Bright', 'url': 'https://ge.xhamster.com/pornstars/mandy-bright'},
{ 'name': 'Antonia Sainz', 'url': 'https://ge.xhamster.com/pornstars/antonia-sainz'},
{ 'name': 'Nicole Love', 'url': 'https://ge.xhamster.com/pornstars/nicole-love'},
{ 'name': 'Sarah Kay', 'url': 'https://ge.xhamster.com/pornstars/sarah-kay'},
{ 'name': 'Judith Kostner', 'url': 'https://ge.xhamster.com/pornstars/judith-kostner'},
{ 'name': 'Maria Bellucci', 'url': 'https://ge.xhamster.com/pornstars/maria-bellucci'},
{ 'name': 'Melissa Monet', 'url': 'https://ge.xhamster.com/pornstars/melissa-monet'},
{ 'name': 'Stephanie Cane', 'url': 'https://ge.xhamster.com/pornstars/stephanie-cane'},
{ 'name': 'Will Steiger', 'url': 'https://ge.xhamster.com/pornstars/will-steiger'},
{ 'name': 'Katty West', 'url': 'https://ge.xhamster.com/pornstars/katty-west'},
{ 'name': 'Jean Pallett', 'url': 'https://ge.xhamster.com/pornstars/jean-pallett'},
{ 'name': 'Conny Dachs', 'url': 'https://ge.xhamster.com/pornstars/conny-dachs'},
{ 'name': 'Juicy Leyla', 'url': 'https://ge.xhamster.com/pornstars/juicy-leyla'},
{ 'name': 'Mandy Mystery', 'url': 'https://ge.xhamster.com/pornstars/mandy-mystery'},
{ 'name': 'Jack Vegas', 'url': 'https://ge.xhamster.com/pornstars/jack-vegas'},
{ 'name': 'Jessica Ryan', 'url': 'https://ge.xhamster.com/pornstars/jessica-ryan'},
{ 'name': 'Nathan Bronson', 'url': 'https://ge.xhamster.com/pornstars/nathan-bronson'},
{ 'name': 'Lexi Lore', 'url': 'https://ge.xhamster.com/pornstars/lexi-lore'},
{ 'name': 'Molly Little', 'url': 'https://ge.xhamster.com/pornstars/molly-little'},
{ 'name': 'Dolly Leigh', 'url': 'https://ge.xhamster.com/pornstars/dolly-leigh'},
{ 'name': 'Marcus London', 'url': 'https://ge.xhamster.com/pornstars/marcus-london'},
{ 'name': 'Kendra Sunderland', 'url': 'https://ge.xhamster.com/pornstars/kendra-sunderland'},
{ 'name': 'Manuel Ferrara', 'url': 'https://ge.xhamster.com/pornstars/manuel-ferrara'},
{ 'name': 'Kelly Trump', 'url': 'https://ge.xhamster.com/pornstars/kelly-trump'},
{ 'name': 'Angelica Heaven', 'url': 'https://ge.xhamster.com/pornstars/angelica-heaven'},
{ 'name': 'Luna Lynx', 'url': 'https://ge.xhamster.com/pornstars/luna-lynx'},
{ 'name': 'Princess Lili', 'url': 'https://ge.xhamster.com/pornstars/princess-lili'},
{ 'name': 'Alexa Wild', 'url': 'https://ge.xhamster.com/pornstars/alexa-wild'},
{ 'name': 'Jessyka Swan', 'url': 'https://ge.xhamster.com/pornstars/jessyka-swan'},
{ 'name': 'Nikky Thorne', 'url': 'https://ge.xhamster.com/pornstars/nikky-thorne'},
{ 'name': 'Tristan Summers', 'url': 'https://ge.xhamster.com/pornstars/tristan-summers'},
{ 'name': 'Rhaya Shyne', 'url': 'https://ge.xhamster.com/pornstars/rhaya-shyne'},
{ 'name': 'Desiree West', 'url': 'https://ge.xhamster.com/pornstars/desiree-west'},
{ 'name': 'Joan Devlon', 'url': 'https://ge.xhamster.com/pornstars/joan-devlon'},
{ 'name': 'Jodi Thorpe', 'url': 'https://ge.xhamster.com/pornstars/jodi-thorpe'},
{ 'name': 'Laurien Dominique', 'url': 'https://ge.xhamster.com/pornstars/laurien-dominique'},
{ 'name': 'Paul Scharf', 'url': 'https://ge.xhamster.com/pornstars/paul-scharf'},
{ 'name': 'Ray Wells', 'url': 'https://ge.xhamster.com/pornstars/ray-wells'},
{ 'name': 'Sandy Carey', 'url': 'https://ge.xhamster.com/pornstars/sandy-carey'},
{ 'name': 'Spender Travis', 'url': 'https://ge.xhamster.com/pornstars/spender-travis'},
{ 'name': 'Starlyn Simone', 'url': 'https://ge.xhamster.com/pornstars/starlyn-simone'},
{ 'name': 'Uschi Digard', 'url': 'https://ge.xhamster.com/pornstars/uschi-digard'},
{ 'name': 'Katarina Muti', 'url': 'https://ge.xhamster.com/pornstars/katarina-muti'},
{ 'name': 'Julia Power', 'url': 'https://ge.xhamster.com/pornstars/julia-power'},
{ 'name': 'Salma De Nora', 'url': 'https://ge.xhamster.com/pornstars/salma-de-nora'},
{ 'name': 'Valeria Jones', 'url': 'https://ge.xhamster.com/pornstars/valeria-jones'},
{ 'name': 'Brandy Canyon', 'url': 'https://ge.xhamster.com/pornstars/brandy-canyon'},
{ 'name': 'Marie Berger', 'url': 'https://ge.xhamster.com/pornstars/marie-berger'},
{ 'name': 'Luna Rishi', 'url': 'https://ge.xhamster.com/pornstars/luna-rishi'},
{ 'name': 'Colleen Brennan', 'url': 'https://ge.xhamster.com/pornstars/colleen-brennan'},
{ 'name': 'Roxanne Brewer', 'url': 'https://ge.xhamster.com/pornstars/roxanne-brewer'},
{ 'name': 'Elle Denay', 'url': 'https://ge.xhamster.com/pornstars/elle-denay'},
{ 'name': 'Juan Largo', 'url': 'https://ge.xhamster.com/pornstars/juan-largo'},
{ 'name': 'Codey Steele', 'url': 'https://ge.xhamster.com/pornstars/codey-steele'},
{ 'name': 'Laney Grey', 'url': 'https://ge.xhamster.com/pornstars/laney-grey'},
{ 'name': 'Stirling Cooper', 'url': 'https://ge.xhamster.com/pornstars/stirling-cooper'},
{ 'name': 'Lana Smalls', 'url': 'https://ge.xhamster.com/pornstars/lana-smalls'},
{ 'name': 'Alex Sanders', 'url':'https://ge.xhamster.com/pornstars/alex-sanders'},
{ 'name': 'Felecia Danay', 'url':'https://ge.xhamster.com/pornstars/felecia-danay'},
{ 'name': 'Lita Chase', 'url':'https://ge.xhamster.com/pornstars/lita-chase'},
{ 'name': 'Mark Ashley', 'url':'https://ge.xhamster.com/pornstars/mark-ashley'},
{ 'name': 'Phyllisha Anne', 'url':'https://ge.xhamster.com/pornstars/phyllisha-anne'},
{ 'name': 'Ryan Conner', 'url':'https://ge.xhamster.com/pornstars/ryan-conner'},
{ 'name': 'Tanya Danielle', 'url':'https://ge.xhamster.com/pornstars/tanya-danielle'},
{ 'name': 'Jessica Moore', 'url':'https://ge.xhamster.com/pornstars/jessica-moore'},
{ 'name': 'Mike Angelo', 'url':'https://ge.xhamster.com/pornstars/mike-angelo'},
{ 'name': 'Morgan Moon', 'url':'https://ge.xhamster.com/pornstars/morgan-moon'},
{ 'name': 'Tyler Steel', 'url':'https://ge.xhamster.com/pornstars/tyler-steel'},
{ 'name': 'Abella Danger', 'url':'https://ge.xhamster.com/pornstars/abella-danger'},
{ 'name': 'Alex Jett', 'url':'https://ge.xhamster.com/pornstars/alex-jett'},
{ 'name': 'Alyson Queen', 'url':'https://ge.xhamster.com/pornstars/alyson-queen'},
{ 'name': 'Antynia Rouge', 'url':'https://ge.xhamster.com/pornstars/antynia-rouge'},
{ 'name': 'Bea Dumas', 'url':'https://ge.xhamster.com/pornstars/bea-dumas'},
{ 'name': 'Callie Black', 'url':'https://ge.xhamster.com/pornstars/callie-black'},
{ 'name': 'Caroline Cage', 'url':'https://ge.xhamster.com/pornstars/caroline-cage'},
{ 'name': 'Cindy Dollar', 'url':'https://ge.xhamster.com/pornstars/cindy-dollar'},
{ 'name': 'Crystal Frost', 'url':'https://ge.xhamster.com/pornstars/crystal-frost'},
{ 'name': 'Fanny Steel', 'url':'https://ge.xhamster.com/pornstars/fanny-steel'},
{ 'name': 'Gia Derza', 'url':'https://ge.xhamster.com/pornstars/gia-derza'},
{ 'name': 'Horst Baron', 'url':'https://ge.xhamster.com/pornstars/horst-baron'},
{ 'name': 'Jasmine Rouge', 'url':'https://ge.xhamster.com/pornstars/jasmine-rouge'},
{ 'name': 'Jean-Pierre Armand', 'url':'https://ge.xhamster.com/pornstars/jean-pierre-armand'},
{ 'name': 'Jessa Rhodes', 'url':'https://ge.xhamster.com/pornstars/jessa-rhodes'},
{ 'name': 'Leonie Saint', 'url':'https://ge.xhamster.com/pornstars/leonie-saint'},
{ 'name': 'Linda Ray', 'url':'https://ge.xhamster.com/pornstars/linda-ray'},
{ 'name': 'Luca Ferrero', 'url':'https://ge.xhamster.com/pornstars/luca-ferrero'},
{ 'name': 'Paris Pink', 'url':'https://ge.xhamster.com/pornstars/paris-pink'},
{ 'name': 'Pavlina Stejskalova', 'url':'https://ge.xhamster.com/pornstars/pavlina-stejskalova'},
{ 'name': 'Phoenix Marie', 'url':'https://ge.xhamster.com/pornstars/phoenix-marie'},
{ 'name': 'Ricky Spanish', 'url':'https://ge.xhamster.com/pornstars/ricky-spanish'},
{ 'name': 'Rumika Powers', 'url':'https://ge.xhamster.com/pornstars/rumika-powers'},
{ 'name': 'Sara Blonde', 'url':'https://ge.xhamster.com/pornstars/sara-blonde'},
{ 'name': 'Sean Lawless', 'url':'https://ge.xhamster.com/pornstars/sean-lawless'},
{ 'name': 'Seth Gamble', 'url':'https://ge.xhamster.com/pornstars/seth-gamble'},
{ 'name': 'Siri Dahl', 'url':'https://ge.xhamster.com/pornstars/siri-dahl'},
{ 'name': 'Stephie Staar', 'url':'https://ge.xhamster.com/pornstars/stephie-staar'},
{ 'name': 'Steve Holmes', 'url':'https://ge.xhamster.com/pornstars/steve-holmes'},
{ 'name': 'Suzette Dale', 'url':'https://ge.xhamster.com/pornstars/suzette-dale'},
{ 'name': 'Uncle George', 'url':'https://ge.xhamster.com/pornstars/uncle-george'},
{ 'name': 'Winnie', 'url':'https://ge.xhamster.com/pornstars/winnie'},
{ 'name': 'Zenza Raggi', 'url':'https://ge.xhamster.com/pornstars/zenza-raggi'},
{ 'name': 'Zorah White', 'url':'https://ge.xhamster.com/pornstars/zorah-white'},
{ 'name': 'Marilyn Jess', 'url':'https://ge.xhamster.com/pornstars/marilyn-jess'},
{ 'name': 'Alexis Capri', 'url':'https://ge.xhamster.com/pornstars/alexis-capri'},
]
for new_actor in new_actor_list:
if new_actor['url'] in actors:
log.warning(f"Actor {new_actor['url']} already persisted")
continue
actor_response = requests.post(f"http://127.0.0.1:8800/api/media/actors", json=new_actor)
log.warning(f"add status: {actor_response.status_code}")
if actor_response.status_code == 201:
log.warning(f"add Actor {new_actor['url']} to existing actor list")
actors[new_actor['url']] = new_actor
actor_data = actor_response.json()
log.warning(f"Actor {actor_data} persisted")
log.warning('kontor.add_actors finished')
-166
View File
@@ -1,166 +0,0 @@
from dataclasses import dataclass
import logging
import logging.config
from logging import Logger
from pathlib import Path
from typing import Dict, List, Optional
from uuid import UUID
from platformdirs import PlatformDirs
import requests
import yaml
MAPPING: Dict[str, str] = {
"sport": "api/tysc/sports",
"player": "api/tysc/players",
"team": "api/tysc/teams",
"field_position": "api/tysc/positions",
"rooster": "api/tysc/roosters",
"vendor": "api/tysc/vendors",
"card_set": "api/tysc/cardsets",
"card": "api/tysc/cards",
"artist": "api/comics/artists",
"publisher": "api/comics/publishers",
"worktype": "api/comics/worktypes",
"comic": "api/comics/comics",
"volume": "api/comics/volumes",
"story_arc": "api/comics/storyarcs",
"issue": "api/comics/issues",
"issue_work": "api/comics/issueworks",
"article": "",
"bookshelf_publisher": "",
"book": "",
"author": "",
"article_author": "",
"book_author": "",
"media_article": "",
"media_video": "api/media/videos",
"media_file": "api/media/files",
"media_actor": "api/media/actors",
"media_actor_file": "api/media/actorfiles",
"profile": "",
"permission": "",
"assignment": "",
"token": "",
"mail_account": "",
}
@dataclass
class Login:
"""
Dataclass to store login information.
"""
email: str
password: str
@dataclass
class Server:
"""
Dataclass to represent a Kontor-API instance.
"""
name: str
url: str
token: str
token_type: str
timeout: int
def login(self, login: Login, log: Logger):
"""
get token from server by calling login endpoint.
"""
if not self.token:
log.info("Call login first")
login_url = f"{self.url}/login"
login_data = {}
login_data["email"] = login.email
login_data["password"] = login.password
response = requests.post(login_url, json=login_data, timeout=self.timeout)
status = response.status_code
log.info(f"Status: {status}")
if status != 200:
log.fatal("authentication failed")
return
data = response.json()
log.debug(f"got data: {data}")
token = data["access_token"]
token_type = data["token_type"]
self.token = str(token)
self.token_type = str(token_type)
def request(self, log: Logger, table: str, param: Optional[str] = None):
if not param:
url: str = f"{self.url}/{MAPPING[table]}"
else:
url: str = f"{self.url}/{MAPPING[table]}?{param}"
headers: Dict[str, str] = {"Authorization": f"Bearer {self.token}"}
response = requests.get(url, headers=headers, timeout=self.timeout)
log.info(f"Status: {response.status_code}")
data = response.json()
return data
def update(self, log: Logger, table: str, item_id: UUID, file_info: dict):
url: str = f"{self.url}/{MAPPING[table]}/{item_id}"
headers: Dict[str, str] = {"Authorization": f"Bearer {self.token}"}
update = requests.put(
url, headers=headers, json=file_info, timeout=self.timeout
)
log.info(f"Status: {update.status_code}")
return update
@dataclass
class ApiConfig:
"""
Dataclass to define required contents of configuration file.
"""
login: Login
server: List[Server]
def get_logger(level, config: str):
"""
get Logger according to given log level by verbosity.
"""
dirs = PlatformDirs(config)
logging_config = Path(dirs.user_config_dir, "logging-config.yaml")
log_config = None
with open(logging_config, "rt", encoding="utf-8") as f:
log_config = yaml.safe_load(f.read())
logging.config.dictConfig(log_config)
logger = logging.getLogger("development")
if level is not None:
match level:
case 0:
logger.setLevel(logging.CRITICAL)
case 1:
logger.setLevel(logging.INFO)
case 2:
logger.setLevel(logging.DEBUG)
case _:
logger.setLevel(logging.INFO)
return logger
def get_api_config(log: Logger, config: str) -> ApiConfig:
dirs = PlatformDirs(config)
api_config = Path(dirs.user_config_dir, "api.yaml")
with open(api_config, "rt") as f:
api_data = yaml.safe_load(f.read())
servers = [Server(**server) for server in api_data["server"]]
login = Login(**(api_data["login"]))
apiConfig = ApiConfig(server=servers, login=login)
log.debug(apiConfig)
if not api_data:
log.fatal("API configuration is missing")
return apiConfig
for server in apiConfig.server:
server.login(apiConfig.login, log)
with open(api_config, "w") as f:
yaml.dump(api_data, f)
return apiConfig
+52
View File
@@ -0,0 +1,52 @@
"""
copy data from SQLite to MariaDB
"""
import sqlite3
from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
from config import get_logger, get_database_cursors, get_meta_data, get_scripts
parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('--recreate-db', action='store_true')
parser.add_argument('--verbose', '-v', action='count', default=0)
args = parser.parse_args()
def copy_data(mariadb_conn, sqlite_conn, table_scripts):
mariadb_cursor = mariadb_conn.cursor()
sqlite_cursor = sqlite_conn.cursor()
# logger.info(table_scripts)
for table_id in scripts:
select_statement = scripts[table_id]['select_sqlite']
# logger.info(select_statement)
insert_statement = scripts[table_id]['insert_mariadb']
mariadb_cursor.execute("SET FOREIGN_KEY_CHECKS = 0")
mariadb_cursor.execute(scripts[table_id]['truncate_mariadb'])
try:
sqlite_cursor.execute(select_statement)
rows = sqlite_cursor.fetchall()
for row in rows:
try:
mariadb_cursor.execute(insert_statement, row)
except sqlite3.Error as error:
logger.info('insert failed with %s\n%s\n%s', error, insert_statement, row)
mariadb_conn.commit()
mariadb_cursor.execute(scripts[table_id]['count'])
(number_of_rows,) = mariadb_cursor.fetchone()
row = sqlite_cursor.execute(scripts[table_id]['count']).fetchone()
logger.info('%s contains %d : %d entries', scripts[table_id]['name'], number_of_rows, row[0])
except sqlite3.Error as error:
logger.info('select failed with %s', error)
if __name__ == '__main__':
logger = get_logger(args.verbose)
logger.info('kontor.copy_to_sqlite started')
s_conn, m_conn = get_database_cursors(logger)
meta_data_tables = get_meta_data(m_conn)
# logger.info(meta_data_tables)
scripts = get_scripts(meta_data_tables, logger)
copy_data(m_conn, s_conn, scripts)
s_conn.close()
m_conn.close()
logger.info('kontor.copy_to_sqlite finished')
+51
View File
@@ -0,0 +1,51 @@
"""
copy data from MariaDB to SQLite
"""
import sqlite3
import mariadb
from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
from config import get_database_cursors, create_tables, get_logger, get_meta_data, get_scripts
parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('--recreate-db', action='store_true')
parser.add_argument('--verbose', '-v', action='count', default=0)
args = parser.parse_args()
def copy_data(mariadb_conn, sqlite_conn, table_scripts):
mariadb_cursor = mariadb_conn.cursor()
sqlite_cursor = sqlite_conn.cursor()
# logger.info(table_scripts)
for table_id in table_scripts:
select_statement = scripts[table_id]['select_mariadb']
# logger.info(select_statement)
insert_statement = scripts[table_id]['insert_sqlite']
try:
mariadb_cursor.execute(select_statement)
rows = mariadb_cursor.fetchall()
for row in rows:
try:
sqlite_cursor.execute(insert_statement, row)
except sqlite3.Error as error:
logger.info('insert failed with %s\n%s\n%s', error, insert_statement, row)
sqlite_conn.commit()
mariadb_cursor.execute(scripts[table_id]['count'])
(number_of_rows,) = mariadb_cursor.fetchone()
row = sqlite_cursor.execute(scripts[table_id]['count']).fetchone()
logger.info('%s contains %d : %d entries', scripts[table_id]['name'], number_of_rows, row[0])
except mariadb.Error as error:
logger.info('select failed with %s', error)
if __name__ == '__main__':
logger = get_logger(args.verbose)
logger.info('kontor.copy_to_sqlite started')
s_conn, m_conn = get_database_cursors(logger)
meta_data_tables = get_meta_data(m_conn)
# logger.info(meta_data_tables)
scripts = get_scripts(meta_data_tables, logger)
create_tables(s_conn, logger, args.recreate_db, scripts)
copy_data(m_conn, s_conn, scripts)
s_conn.close()
m_conn.close()
logger.info('kontor.copy_to_sqlite finished')
+67
View File
@@ -0,0 +1,67 @@
"""
Prints the database kontor structure
"""
import mariadb
from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
from config import get_database_cursors, get_logger
parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('--verbose', '-v', action='count', default=0)
args = parser.parse_args()
def show_tables(cur, log):
"""
Retrieves the list of tables from the database
:param cur:
:param log:
:return:
"""
log.info('get list of tables')
table_list = []
cur.execute("SHOW TABLES")
for (tablename,) in cur.fetchall():
table_list.append(tablename)
return table_list
def get_field_info(cur):
"""
Retrieves the field info associated with a cursor
:param cur:
:return:
"""
field_info = mariadb.fieldinfo()
field_info_text_list = []
for column in cur.description:
column_name = column[0]
column_type = field_info.type(column)
column_flags = field_info.flag(column)
field_info_text_list.append(f"{column_name}: {column_type} {column_flags}")
return field_info_text_list
def get_table_field_info(cur, tablename):
"""
Retrieves the field info associated with a table
:param cur:
:param tablename:
:return:
"""
cur.execute(f"SELECT * FROM {tablename} LIMIT 1")
field_info = get_field_info(cur)
return field_info
if __name__ == '__main__':
logger = get_logger(args.verbose)
logger.info("kontor.db_structure started")
_, mariadb_conn = get_database_cursors(logger)
tables = show_tables(mariadb_conn.cursor(), logger)
for table in tables:
field_info_text = get_table_field_info(mariadb_conn.cursor(), table)
print(f"Columns in table {table}:")
print("\n".join(field_info_text))
print("\n")
mariadb_conn.close()
logger.info("kontor.db_structure finished")
+22 -17
View File
@@ -2,6 +2,7 @@
download files with URLs from DB download files with URLs from DB
""" """
import os
import re import re
import subprocess import subprocess
import sys import sys
@@ -9,17 +10,16 @@ from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser
from datetime import datetime from datetime import datetime
from enum import Enum, auto from enum import Enum, auto
from pathlib import Path from pathlib import Path
from typing import Dict from typing import Any, Dict
from logging import Logger
from uuid import UUID from uuid import UUID
import requests import requests
from api import Server, get_api_config, get_logger from config import get_api_config, get_logger
parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter) parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument("--verbose", "-v", action="count", default=0) parser.add_argument("--verbose", "-v", action="count", default=0)
parser.add_argument("--config", "-c", default="kontor-api") parser.add_argument("--config", "-c", default="kontor-docker")
parser.add_argument("--dir", "-d", default="/data/media") parser.add_argument("--dir", "-d", default="/data/media")
parser.add_argument("--limit", "-l", type=int, help="maximum number of links to check") parser.add_argument("--limit", "-l", type=int, help="maximum number of links to check")
parser.add_argument("--tool", "-t", default="yt-dlp") parser.add_argument("--tool", "-t", default="yt-dlp")
@@ -104,8 +104,12 @@ def is_file_downloaded(media_file: dict, dir: Path) -> FileStatus:
return FileStatus.UNKNOWN return FileStatus.UNKNOWN
def update_status(item_id: UUID, file_info: dict, server: Server, log: Logger): def update_status(item_id: UUID, file_info: dict, api_data: Dict[str, Any]):
update = server.update(log, "media_file", item_id, file_info) host = api_data["host"]
token = api_data["token"]
url: str = f"http://{host}:{port}/api/media/files/{item_id}"
headers: Dict[str, str] = {"Authorization": f"Bearer {token}"}
update = requests.put(url, headers=headers, json=file_info)
log.info(f"update status: {update.status_code}") log.info(f"update status: {update.status_code}")
log.info(f"update result: {update.json()}") log.info(f"update result: {update.json()}")
@@ -128,14 +132,15 @@ def rename_file(file_info: dict):
if __name__ == "__main__": if __name__ == "__main__":
log = get_logger(args.verbose, args.config) log = get_logger(args.verbose, args.config)
log.info("kontor.download started") log.info("kontor.download started")
apiConfig = get_api_config(log, args.config) api_data = get_api_config(log, args.config)
server: Server = apiConfig.server[0] host = api_data["host"]
data = server.request(log=log, table="media_file", param="download=true") port = api_data["port"]
# url: str = f"http://{host}:{port}/api/media/files?download=true" token = api_data["token"]
# headers: Dict[str, str] = {"Authorization": f"Bearer {token}"} url: str = f"http://{host}:{port}/api/media/files?download=true"
# response = requests.get(url, headers=headers) headers: Dict[str, str] = {"Authorization": f"Bearer {token}"}
# log.info(f"Status: {response.status_code}") response = requests.get(url, headers=headers)
# data = response.json() log.info(f"Status: {response.status_code}")
data = response.json()
entries_count = len(data) entries_count = len(data)
log.info(f"data: {entries_count}") log.info(f"data: {entries_count}")
mediafile_index = 1 mediafile_index = 1
@@ -153,15 +158,15 @@ if __name__ == "__main__":
match download_status: match download_status:
case FileStatus.DOWNLOADED: case FileStatus.DOWNLOADED:
rename_file(item) rename_file(item)
update_status(file_id, item, server=server, log=log) update_status(file_id, item, api_data)
case FileStatus.RENAMED: case FileStatus.RENAMED:
log.info("update status") log.info("update status")
update_status(file_id, item, server=server, log=log) update_status(file_id, item, api_data)
case FileStatus.UNKNOWN: case FileStatus.UNKNOWN:
download_file(link, item, args.dir) download_file(link, item, args.dir)
rename_file(item) rename_file(item)
log.info(f"{item}") log.info(f"{item}")
update_status(file_id, item, server=server, log=log) update_status(file_id, item, api_data)
log.warning(f"processed {mediafile_index}/{entries_count}") log.warning(f"processed {mediafile_index}/{entries_count}")
if args.limit and args.limit <= mediafile_index: if args.limit and args.limit <= mediafile_index:
break break
+58
View File
@@ -0,0 +1,58 @@
"""
copy data from JSON to MariaDB
"""
from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
from pathlib import Path
from config import get_logger, get_database_cursors
import mariadb
import json
parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('--verbose', '-v', action='count', default=0)
parser.add_argument('--config', '-c', default='kontor-docker')
parser.add_argument('--file', '-f', default='~/.sync/media/data.json')
args = parser.parse_args()
def copy_data(mariadb_conn, data_file: Path, log):
mariadb_cursor = mariadb_conn.cursor()
import_file = Path(data_file)
if not import_file.exists():
log.info(f"File {data_file} does not exist. Do nothing.")
return
log.info("read json file")
with open(data_file, 'r') as json_file:
json_load = json.load(json_file)
for table in json_load:
log.info(f"{table}: {len(json_load[table])}")
# result[table] = import_table(table, json_load[table])
truncate_statement = 'TRUNCATE {}'.format(table)
#log.info(f"truncate: {truncate_statement}")
mariadb_cursor.execute("SET FOREIGN_KEY_CHECKS = 0")
mariadb_cursor.execute(truncate_statement)
items = json_load[table]
for item in items:
#log.info(f"item: {item}")
values = []
columns = []
for (key, value) in item.items():
columns.append(key)
values.append(value)
row = tuple(values)
log.info(f"values: {row}")
insert_statement = 'INSERT INTO {}({}) VALUES({})'.format(table, ', '.join(columns), ', '.join(['?']*len(columns)))
#log.info(f"statement: {insert_statement}")
mariadb_cursor.execute(insert_statement, row)
try:
mariadb_conn.commit()
except mariadb.Error as error:
log.info('insert failed with %s', error)
if __name__ == '__main__':
logger = get_logger(args.verbose, args.config)
logger.info('kontor.json_to_mariadb started')
_, m_conn = get_database_cursors(logger, args.config)
copy_data(m_conn, args.file, logger)
m_conn.close()
logger.info('kontor.json_to_mariadb finished')
+152
View File
@@ -0,0 +1,152 @@
"""
copy data from JSON to Postgres
"""
from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
from pathlib import Path
from typing import Dict, List
from config import get_logger, get_database_cursors
import json
from psycopg2.sql import SQL
parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('--verbose', '-v', action='count', default=0)
parser.add_argument('--config', '-c', default='kontor-docker')
parser.add_argument('--file', '-f', default='~/.sync/media/data.json')
args = parser.parse_args()
def copy_data(postgres_conn, data_file: Path, log):
postgres_cursor = postgres_conn.cursor()
import_file = Path(data_file)
if not import_file.exists():
log.info(f"File {data_file} does not exist. Do nothing.")
return
log.info("read json file")
with open(data_file, 'r') as json_file:
json_load = json.load(json_file)
postgres_cursor.execute("SET session_replication_role='replica'")
for table in json_load:
log.info(f"{table}: {len(json_load[table])}")
# result[table] = import_table(table, json_load[table])
truncate_statement = 'TRUNCATE {} CASCADE'.format(table)
#log.info(f"truncate: {truncate_statement}")
try:
postgres_cursor.execute(truncate_statement)
except:
log.info(f"statement: {insert_statement} FAILED")
items = json_load[table]
for item in items:
#log.info(f"item: {item}")
values = []
columns = []
for (key, value) in item.items():
columns.append(key)
values.append(value)
row = tuple(values)
#log.info(f"values: {row}")
insert_statement = 'INSERT INTO {}({}) VALUES({})'.format(table, ', '.join(columns), ', '.join(['%s']*len(columns)))
#log.info(f"statement: {insert_statement}")
try:
postgres_cursor.execute(SQL(insert_statement), row)
postgres_conn.commit()
except:
log.info(f'insert failed with {insert_statement}')
postgres_cursor.execute("SET session_replication_role='origin'")
def load_json(data_file, log) -> dict:
import_file = Path(data_file)
if not import_file.exists():
log.info(f"File {data_file} does not exist. Do nothing.")
return
log.info("read json file")
with open(data_file, 'r') as json_file:
json_load = json.load(json_file)
return json_load
def insert_data(postgres_conn, data: dict, log):
postgres_cursor = postgres_conn.cursor()
log.info("insert data")
table_list = []
table_list = ['worktype', 'artist', 'publisher', 'volume', 'comic', 'issue', 'story_arc', 'trade_paperback', 'comic_work']
table_list.extend(['sport', 'team', 'field_position', 'vendor', 'player', 'rooster', 'card_set', 'card'])
#table_list.extend(['card'])
table_list.extend(['media_file', 'media_video', 'media_actor', 'media_article', 'media_actor_file'])
#table_list.extend(['media_actor_file'])
table_list.extend(['profile', 'permission', 'token', 'assignment'])
table_list.extend(['mail', 'mail_account', 'module_data', 'meta_data_table', 'meta_data_column'])
table_list.extend(['meta_data_table', 'meta_data_column'])
table_list.extend(['book', 'author', 'article', 'bookshelf_publisher', 'book_author', 'article_author'])
#if len(table_list) != 37:
# log.info(f"number of tables incorrect: {len(table_list)}")
# return
for table in table_list:
log.info(f"{table}: {len(data[table])}")
truncate_statement = 'DELETE FROM {}'.format(table)
log.info(f"truncate: {truncate_statement}")
try:
postgres_cursor.execute(truncate_statement)
postgres_conn.commit()
except:
log.info(f"statement: {truncate_statement} FAILED")
items = data[table]
for item in items:
# log.info(f"item: {item}")
values = []
columns = []
for (key, value) in item.items():
columns.append(key)
values.append(value)
row = tuple(values)
# log.info(f"values: {row}")
insert_statement = 'INSERT INTO {}({}) VALUES({})'.format(table, ', '.join(columns),
', '.join(['%s'] * len(columns)))
# log.info(f"statement: {insert_statement}")
try:
postgres_cursor.execute(SQL(insert_statement), row)
postgres_conn.commit()
except:
log.info(f'insert failed with {insert_statement}')
def parse_table_order(data: dict, log):
log.info("parse_table_order")
table_refs: Dict[str, List[str]] = {}
for table in data:
log.info(f"{table}: {len(data[table])}")
items = data[table]
table_refs[table] = []
if len(items) == 0:
continue
item = items[0]
for key, _ in item.items():
if key.endswith('_id'):
ref = key[0:-3]
log.info(f"table {table} has reference to {ref}")
if table in table_refs:
table_refs[table].append(ref)
else:
table_refs[table] = [ref]
log.info(f"parsed refs: {table_refs}")
table_order = []
for table in table_refs:
if len(table_refs[table]) == 0:
log.info(f"insert {table} at beginning")
table_order.insert(0, table)
else:
log.info(f"insert {table} at end")
table_order.append(table)
log.info(f"table_list: {len(table_order)}: {table_order}")
if __name__ == '__main__':
logger = get_logger(args.verbose, args.config)
logger.info('kontor.json_to_postgres started')
_, _, p_conn = get_database_cursors(logger, args.config)
data = load_json(args.file, logger)
#parse_table_order(data, logger)
insert_data(p_conn, data, logger)
#copy_data(p_conn, args.file, logger)
p_conn.close()
logger.info('kontor.json_to_postgres finished')
+64
View File
@@ -0,0 +1,64 @@
from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
from pathlib import Path
import logging.config
import yaml
from platformdirs import PlatformDirs
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from schema import Comic, Publisher, Base
parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('--recreate-db', action='store_true')
parser.add_argument('--verbose', '-v', action='count', default=0)
parser.add_argument('--file', '-f', default='~/data.json')
parser.add_argument('--config', '-c', default='kontor-docker')
args = parser.parse_args()
def get_logger(level: int, config: str):
dirs = PlatformDirs(config)
logging_config = Path(dirs.user_config_dir, 'logging-config.yaml')
with open(logging_config, 'rt') as f:
configDict = yaml.safe_load(f.read())
logging.config.dictConfig(configDict)
logger = logging.getLogger('development')
if level is not None:
match level:
case 0:
logger.setLevel(logging.INFO)
case 1:
logger.setLevel(logging.DEBUG)
case _:
logger.setLevel(logging.CRITICAL)
return logger
if __name__ == '__main__':
log = get_logger(args.verbose, args.config)
log.info('kontor started')
dirs = PlatformDirs(args.config)
database_config = Path(dirs.user_config_dir, 'database-config.yaml')
with open(database_config, 'rt') as f:
db_config = yaml.safe_load(f.read())
print(db_config)
connect_string = ('mariadb+mariadbconnector://{}:{}@{}:{}/{}'.format(
db_config['mariadb']['user'],
db_config['mariadb']['password'],
db_config['mariadb']['host'],
db_config['mariadb']['port'],
db_config['mariadb']['database']
))
engine = create_engine(connect_string)
Base.metadata.create_all(bind=engine, checkfirst=True)
__session__ = sessionmaker(engine)
with __session__() as session:
comics = session.scalars(select(Comic)).all()
for comic in comics:
print(comic)
publishers = session.scalars(select(Publisher)).all()
for publisher in publishers:
print(publisher)
print(len(publisher.comics))
log.info('kontor finished')
+80
View File
@@ -0,0 +1,80 @@
"""
read file with URLs and store in DB
"""
from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
import logging
import json
from proton import Message, Event
from proton.handlers import MessagingHandler
from proton.reactor import Container
parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('-f', '--links', help='file with links')
parser.add_argument('--verbose', '-v', action='count', default=0)
args = parser.parse_args()
def read_links_file(links_file):
with open(links_file, 'r') as input_file:
lines = input_file.readlines()
return lines
class AddLinkMessage(MessagingHandler):
def __init__(self, server, url, log):
super(AddLinkMessage, self).__init__()
log.info("create AddLinkMessage")
self.server = server
self.address = "KontorMediaFile::add_link_file"
self.url = url
self.log = log
def on_start(self, event: Event):
self.log.info("Connection...")
conn = event.container.connect(self.server, user="artemis", password="artemis")
event.container.create_sender(conn, self.address)
def on_connection_opened(self, event: Event) -> None:
self.log.debug("connection open")
def on_connection_error(self, event: Event) -> None:
self.log.info(f"error: {event}")
def on_disconnected(self, event: Event) -> None:
self.log.debug(f"disconnected: {repr(event)}")
def on_sendable(self, event: Event):
self.log.info("send message")
message = Message(body=self.url, address=self.address, content_type="application/json", durable=True)
delivery = event.sender.send(message)
self.log.info(f"Delivery {delivery} sent")
event.connection.close()
def on_accepted(self, event: Event) -> None:
self.log.info(f"accepted Delivery: {event.delivery.remote_state}")
def on_rejected(self, event: Event) -> None:
self.log.info(f"rejected Delivery: {event.delivery}")
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s')
logging.info('kontor.read_list started')
#conn = stomp.Connection([('127.0.0.1', '61616')])
#conn.connect('artemis', 'artemis', wait=True)
if args.links:
logging.info("read links from file")
links = read_links_file(args.links)
for link in links:
data_dict = {'url': link.strip()}
data = json.dumps(data_dict)
logging.info("send link message")
handler = AddLinkMessage("amqp://127.0.0.1:5672", data, logging)
container = Container(handler)
container.container_id = "process_add_links"
container.run()
# conn.send(body=data, destination='KontorMediaFile::add_link_file', headers={'content-type': 'application/json'})
#conn.disconnect()
logging.info('kontor.read_list finished')
+9
View File
@@ -0,0 +1,9 @@
sqlalchemy
pathlib
platformdirs
pyyaml
beautifulsoup4
sqlmodel
requests
fastapi[standard]
-21
View File
@@ -1,21 +0,0 @@
from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser
from dataclasses import dataclass
from api import get_logger, get_api_config
parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument("--verbose", "-v", action="count", default=0)
parser.add_argument("--config", "-c", default="kontor-api")
parser.add_argument("--dry-run", "-m", action="store_true")
parser.add_argument("--cleanup", "-d", action="store_true")
args = parser.parse_args()
if __name__== "__main__":
logger = get_logger(args.verbose, "kontor")
logger.info("kontor.sync started")
apiConfig = get_api_config(logger, args.config)
for server in apiConfig.server:
data = server.request(logger, "media_file")
logger.info(len(data))
logger.info("kontor.sync finished")
-1
View File
@@ -30,7 +30,6 @@ plugins {
alias(libs.plugins.asciidoctorPdf) alias(libs.plugins.asciidoctorPdf)
alias(libs.plugins.asciidoctorConvert) alias(libs.plugins.asciidoctorConvert)
alias(libs.plugins.asciidoctorGems) alias(libs.plugins.asciidoctorGems)
id "de.infolektuell.typst" version "0.8.0"
} }
repositories { repositories {
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=http://localhost:4000
+12
View File
@@ -0,0 +1,12 @@
/* eslint-env node */
module.exports = {
"root": true,
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"env": {
"vue/setup-compiler-macros": true,
"vue/multi-word-component-names": false
}
}
+5 -1
View File
@@ -8,15 +8,19 @@ pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
.DS_Store
dist dist
dist-ssr dist-ssr
coverage
*.local *.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
.DS_Store
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj
+2 -3
View File
@@ -1,4 +1,4 @@
FROM docker.io/node:20-alpine AS build FROM node:22.18.0-bookworm-slim AS build
WORKDIR /app WORKDIR /app
ENV PATH=/app/node_modules/.bin:$PATH ENV PATH=/app/node_modules/.bin:$PATH
COPY package.json /app/package.json COPY package.json /app/package.json
@@ -7,8 +7,7 @@ RUN npm install -g @vue/cli
COPY . /app COPY . /app
RUN npm run build RUN npm run build
FROM docker.io/library/nginx:stable-alpine FROM nginx:1.29.0-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+3 -3
View File
@@ -1,5 +1,5 @@
# Vue 3 + Vite # vue-3-pinia-registration-login-example
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. Vue 3 + Pinia - User Registration and Login Example & Tutorial
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support). Documentation at https://jasonwatmore.com/post/2022/07/25/vue-3-pinia-user-registration-and-login-example-tutorial
+19 -3
View File
@@ -1,13 +1,29 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>kontor-vue</title> <title>Vue 3 + Pinia - User Registration and Login Example & Tutorial</title>
<!-- bootstrap css -->
<link href="//netdna.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
<!-- credits -->
<div class="text-center mt-4">
<p>
<a href="https://jasonwatmore.com/post/2022/07/25/vue-3-pinia-user-registration-and-login-example-tutorial">Vue 3 + Pinia - User Registration and Login Example & Tutorial</a>
</p>
<p>
<a href="https://jasonwatmore.com">JasonWatmore.com</a>
</p>
</div>
</body> </body>
</html> </html>
-38
View File
@@ -1,38 +0,0 @@
server {
listen 8700;
# Root-Verzeichnis für den Server setzen (wir kopieren unsere Anwendung hierher)
root /usr/share/nginx/html;
# Definieren der Standard-Indexdatei (Angular erstellt die Datei index.html für uns und sie befindet sich im oben genannten Verzeichnis)
index index.html;
# Cache-Header für Medien-ASsets
location ~* \.(?:cur|jpe?g|gif|htc|ico|png|xml|otf|ttf|eot|woff|woff2|svg)$ {
access_log off;
add_header Pragma "must-revalidate, public";
add_header Cache-Control "must-revalidate, public";
expires max;
tcp_nodelay off;
}
# Cache-Header für HTML, CSS und JS-Dateien
location ~* \.(?:css|js|html)$ {
access_log off;
add_header Pragma "must-revalidate, public";
add_header Cache-Control "must-revalidate, public";
expires 2d;
tcp_nodelay off;
}
# Konfiguration für den /-Pfad
location / {
# Zunächst versuchen wir die angeforderte URI auzuliefern
# Klappt das nicht, versuchen wir es mit einem abschließenden Slash
# Klappt auch das nicht, liefern wir die index.html aus.
# Das ist nötig, damit Angular-Routen korrekt augeflöst und ausgeliefert werden
try_files $uri $uri/ /index.html;
}
}
+2731 -1261
View File
File diff suppressed because it is too large Load Diff
+12 -18
View File
@@ -1,29 +1,23 @@
{ {
"name": "kontor-vue", "name": "vue-3-pinia-registration-login-example",
"private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview --port 5050",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.2.0", "pinia": "^2.0.13",
"@fortawesome/free-solid-svg-icons": "^7.2.0", "vee-validate": "^4.5.11",
"@fortawesome/vue-fontawesome": "^3.1.3", "vue": "^3.2.33",
"axios": "^1.14.0", "vue-router": "^4.0.14",
"bootstrap": "^5.3.8", "yup": "^0.32.11"
"jquery": "^4.0.0",
"popper.js": "^1.16.1",
"vee-validate": "^4.15.1",
"vue": "^3.5.30",
"vue-router": "^4.6.4",
"vuex": "^4.1.0",
"yup": "^1.7.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^2.3.1",
"vite": "^8.0.1" "eslint": "^8.5.0",
"eslint-plugin-vue": "^8.2.0",
"vite": "^2.9.5"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

+14 -78
View File
@@ -1,84 +1,20 @@
<script setup>
import { Nav, Alert } from '@/components';
import { useAuthStore } from '@/stores';
const authStore = useAuthStore();
</script>
<template> <template>
<div id="app"> <div class="app-container" :class="authStore.user && 'bg-light'">
<nav class="navbar navbar-expand navbar-dark bg-dark"> <Nav />
<a href="/" class="navbar-brand">Kontor</a> <Alert />
<div class="navbar-nav mr-auto"> <div class="container pt-4 pb-4">
<li class="nav-item">
<router-link to="/home" class="nav-link">
<font-awesome-icon icon="home" /> Home
</router-link>
</li>
<li v-if="showAdminBoard" class="nav-item">
<router-link to="/admin" class="nav-link">Admin Board</router-link>
</li>
<li v-if="showModeratorBoard" class="nav-item">
<router-link to="/mod" class="nav-link">Moderator Board</router-link>
</li>
<li class="nav-item">
<router-link v-if="currentUser" to="/user" class="nav-link">User</router-link>
</li>
</div>
<div v-if="!currentUser" class="navbar-nav ml-auto">
<li class="nav-item">
<router-link to="/register" class="nav-link">
<font-awesome-icon icon="user-plus" /> Sign Up
</router-link>
</li>
<li class="nav-item">
<router-link to="/login" class="nav-link">
<font-awesome-icon icon="sign-in-alt" /> Login
</router-link>
</li>
</div>
<div v-if="currentUser" class="navbar-nav ml-auto">
<li class="nav-item">
<router-link to="/profile" class="nav-link">
<font-awesome-icon icon="user" />
{{ currentUser.username }}
</router-link>
</li>
<li class="nav-item">
<a class="nav-link" @click.prevent="logOut">
<font-awesome-icon icon="sign-out-alt" /> LogOut
</a>
</li>
</div>
</nav>
<div class="container">
<router-view /> <router-view />
</div> </div>
</div> </div>
</template> </template>
<script> <style>
export default { @import '@/assets/base.css';
computed: { </style>
currentUser() {
return this.$store.state.auth.user;
},
showAdminBoard() {
if (this.currentUser && this.currentUser['roles']) {
return this.currentUser['roles'].includes('ROLE_ADMIN');
}
return false;
},
showModeratorBoard() {
if (this.currentUser && this.currentUser['roles']) {
return this.currentUser['roles'].includes('ROLE_MODERATOR');
}
return false;
}
},
methods: {
logOut() {
this.$store.dispatch('auth/logout');
this.$router.push('/login');
}
}
};
</script>
+9
View File
@@ -0,0 +1,9 @@
.app-container {
min-height: 350px;
}
.btn-delete-user {
width: 40px;
text-align: center;
box-sizing: content-box;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

+19
View File
@@ -0,0 +1,19 @@
<script setup>
import { storeToRefs } from 'pinia';
import { useAlertStore } from '@/stores';
const alertStore = useAlertStore();
const { alert } = storeToRefs(alertStore);
</script>
<template>
<div v-if="alert" class="container">
<div class="m-3">
<div class="alert alert-dismissable" :class="alert.type">
<button @click="alertStore.clear()" class="btn btn-link close">&times;</button>
{{alert.message}}
</div>
</div>
</div>
</template>
-36
View File
@@ -1,36 +0,0 @@
<template>
<div class="container">
<header class="jumbotron">
<h3>{{ content }}</h3>
</header>
</div>
</template>
<script>
import UserService from "../services/user.service";
export default {
name: "Admin",
data() {
return {
content: "",
};
},
mounted() {
UserService.getAdminBoard().then(
(response) => {
this.content = response.data;
},
(error) => {
this.content =
(error.response &&
error.response.data &&
error.response.data.message) ||
error.message ||
error.toString();
}
);
},
};
</script>
@@ -1,36 +0,0 @@
<template>
<div class="container">
<header class="jumbotron">
<h3>{{ content }}</h3>
</header>
</div>
</template>
<script>
import UserService from "../services/user.service";
export default {
name: "Moderator",
data() {
return {
content: "",
};
},
mounted() {
UserService.getModeratorBoard().then(
(response) => {
this.content = response.data;
},
(error) => {
this.content =
(error.response &&
error.response.data &&
error.response.data.message) ||
error.message ||
error.toString();
}
);
},
};
</script>
-35
View File
@@ -1,35 +0,0 @@
<template>
<div class="container">
<header class="jumbotron">
<h3>{{ content }}</h3>
</header>
</div>
</template>
<script>
import UserService from "../services/user.service";
export default {
name: "User",
data() {
return {
content: "",
};
},
mounted() {
UserService.getUserBoard().then(
(response) => {
this.content = response.data;
},
(error) => {
this.content =
(error.response &&
error.response.data &&
error.response.data.message) ||
error.message ||
error.toString();
}
);
},
};
</script>
-85
View File
@@ -1,85 +0,0 @@
<script setup>
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button class="counter" @click="count++">Count is {{ count }}</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>
-35
View File
@@ -1,35 +0,0 @@
<template>
<div class="container">
<header class="jumbotron">
<h3>{{ content }}</h3>
</header>
</div>
</template>
<script>
import UserService from "../services/user.service";
export default {
name: "Home",
data() {
return {
content: "",
};
},
mounted() {
UserService.getPublicContent().then(
(response) => {
this.content = response.data;
},
(error) => {
this.content =
(error.response &&
error.response.data &&
error.response.data.message) ||
error.message ||
error.toString();
}
);
},
};
</script>
-96
View File
@@ -1,96 +0,0 @@
<template>
<div class="col-md-12">
<div class="card card-container">
<img
id="profile-img"
src="//ssl.gstatic.com/accounts/ui/avatar_2x.png"
class="profile-img-card"
/>
<Form @submit="handleLogin" :validation-schema="schema">
<div class="form-group">
<label for="username">Username</label>
<Field name="username" type="text" class="form-control" />
<ErrorMessage name="username" class="error-feedback" />
</div>
<div class="form-group">
<label for="password">Password</label>
<Field name="password" type="password" class="form-control" />
<ErrorMessage name="password" class="error-feedback" />
</div>
<div class="form-group">
<button class="btn btn-primary btn-block" :disabled="loading">
<span
v-show="loading"
class="spinner-border spinner-border-sm"
></span>
<span>Login</span>
</button>
</div>
<div class="form-group">
<div v-if="message" class="alert alert-danger" role="alert">
{{ message }}
</div>
</div>
</Form>
</div>
</div>
</template>
<script>
import { Form, Field, ErrorMessage } from "vee-validate";
import * as yup from "yup";
export default {
name: "Login",
components: {
Form,
Field,
ErrorMessage,
},
data() {
const schema = yup.object().shape({
username: yup.string().required("Username is required!"),
password: yup.string().required("Password is required!"),
});
return {
loading: false,
message: "",
schema,
};
},
computed: {
loggedIn() {
return this.$store.state.auth.status.loggedIn;
},
},
created() {
if (this.loggedIn) {
this.$router.push("/profile");
}
},
methods: {
handleLogin(user) {
this.loading = true;
this.$store.dispatch("auth/login", user).then(
() => {
this.$router.push("/profile");
},
(error) => {
this.loading = false;
this.message =
(error.response &&
error.response.data &&
error.response.data.message) ||
error.message ||
error.toString();
}
);
},
},
};
</script>
+17
View File
@@ -0,0 +1,17 @@
<script setup>
import { useAuthStore } from '@/stores';
const authStore = useAuthStore();
</script>
<template>
<nav v-show="authStore.user" class="navbar navbar-expand navbar-dark bg-dark">
<div class="navbar-nav">
<router-link to="/" class="nav-item nav-link">Home</router-link>
<router-link to="/comics" class="nav-item nav-link">Comics</router-link>
<router-link to="/Media" class="nav-item nav-link">Media</router-link>
<router-link to="/users" class="nav-item nav-link">Users</router-link>
<button @click="authStore.logout()" class="btn btn-link nav-item nav-link">Logout</button>
</div>
</nav>
</template>
-41
View File
@@ -1,41 +0,0 @@
<template>
<div class="container">
<header class="jumbotron">
<h3>
<strong>{{currentUser.username}}</strong> Profile
</h3>
</header>
<p>
<strong>Token:</strong>
{{currentUser.accessToken.substring(0, 20)}} ... {{currentUser.accessToken.substr(currentUser.accessToken.length - 20)}}
</p>
<p>
<strong>Id:</strong>
{{currentUser.id}}
</p>
<p>
<strong>Email:</strong>
{{currentUser.email}}
</p>
<strong>Authorities:</strong>
<ul>
<li v-for="role in currentUser.roles" :key="role">{{role}}</li>
</ul>
</div>
</template>
<script>
export default {
name: 'Profile',
computed: {
currentUser() {
return this.$store.state.auth.user;
}
},
mounted() {
if (!this.currentUser) {
this.$router.push('/login');
}
}
};
</script>
-123
View File
@@ -1,123 +0,0 @@
<template>
<div class="col-md-12">
<div class="card card-container">
<img
id="profile-img"
src="//ssl.gstatic.com/accounts/ui/avatar_2x.png"
class="profile-img-card"
/>
<Form @submit="handleRegister" :validation-schema="schema">
<div v-if="!successful">
<div class="form-group">
<label for="username">Username</label>
<Field name="username" type="text" class="form-control" />
<ErrorMessage name="username" class="error-feedback" />
</div>
<div class="form-group">
<label for="email">Email</label>
<Field name="email" type="email" class="form-control" />
<ErrorMessage name="email" class="error-feedback" />
</div>
<div class="form-group">
<label for="password">Password</label>
<Field name="password" type="password" class="form-control" />
<ErrorMessage name="password" class="error-feedback" />
</div>
<div class="form-group">
<button class="btn btn-primary btn-block" :disabled="loading">
<span
v-show="loading"
class="spinner-border spinner-border-sm"
></span>
Sign Up
</button>
</div>
</div>
</Form>
<div
v-if="message"
class="alert"
:class="successful ? 'alert-success' : 'alert-danger'"
>
{{ message }}
</div>
</div>
</div>
</template>
<script>
import { Form, Field, ErrorMessage } from "vee-validate";
import * as yup from "yup";
export default {
name: "Register",
components: {
Form,
Field,
ErrorMessage,
},
data() {
const schema = yup.object().shape({
username: yup
.string()
.required("Username is required!")
.min(3, "Must be at least 3 characters!")
.max(20, "Must be maximum 20 characters!"),
email: yup
.string()
.required("Email is required!")
.email("Email is invalid!")
.max(50, "Must be maximum 50 characters!"),
password: yup
.string()
.required("Password is required!")
.min(6, "Must be at least 6 characters!")
.max(40, "Must be maximum 40 characters!"),
});
return {
successful: false,
loading: false,
message: "",
schema,
};
},
computed: {
loggedIn() {
return this.$store.state.auth.status.loggedIn;
},
},
mounted() {
if (this.loggedIn) {
this.$router.push("/profile");
}
},
methods: {
handleRegister(user) {
this.message = "";
this.successful = false;
this.loading = true;
this.$store.dispatch("auth/register", user).then(
(data) => {
this.message = data.message;
this.successful = true;
this.loading = false;
},
(error) => {
this.message =
(error.response &&
error.response.data &&
error.response.data.message) ||
error.message ||
error.toString();
this.successful = false;
this.loading = false;
}
);
},
},
};
</script>
+2
View File
@@ -0,0 +1,2 @@
export { default as Alert } from './Alert.vue';
export { default as Nav } from './Nav.vue';
+149
View File
@@ -0,0 +1,149 @@
export { fakeBackend };
// array in local storage for registered users
const usersKey = 'vue-3-pinia-registration-login-example-users';
let users = JSON.parse(localStorage.getItem(usersKey)) || [];
function fakeBackend() {
let realFetch = window.fetch;
window.fetch = function (url, opts) {
return new Promise((resolve, reject) => {
// wrap in timeout to simulate server api call
setTimeout(handleRoute, 500);
function handleRoute() {
switch (true) {
case url.endsWith('/users/authenticate') && opts.method === 'POST':
return authenticate();
case url.endsWith('/users/register') && opts.method === 'POST':
return register();
case url.endsWith('/users') && opts.method === 'GET':
return getUsers();
case url.match(/\/users\/\d+$/) && opts.method === 'GET':
return getUserById();
case url.match(/\/users\/\d+$/) && opts.method === 'PUT':
return updateUser();
case url.match(/\/users\/\d+$/) && opts.method === 'DELETE':
return deleteUser();
default:
// pass through any requests not handled above
return realFetch(url, opts)
.then(response => resolve(response))
.catch(error => reject(error));
}
}
// route functions
function authenticate() {
const { username, password } = body();
const user = users.find(x => x.username === username && x.password === password);
if (!user) return error('Username or password is incorrect');
return ok({
...basicDetails(user),
token: 'fake-jwt-token'
});
}
function register() {
const user = body();
if (users.find(x => x.username === user.username)) {
return error('Username "' + user.username + '" is already taken')
}
user.id = users.length ? Math.max(...users.map(x => x.id)) + 1 : 1;
users.push(user);
localStorage.setItem(usersKey, JSON.stringify(users));
return ok();
}
function getUsers() {
if (!isAuthenticated()) return unauthorized();
return ok(users.map(x => basicDetails(x)));
}
function getUserById() {
if (!isAuthenticated()) return unauthorized();
const user = users.find(x => x.id === idFromUrl());
return ok(basicDetails(user));
}
function updateUser() {
if (!isAuthenticated()) return unauthorized();
let params = body();
let user = users.find(x => x.id === idFromUrl());
// only update password if entered
if (!params.password) {
delete params.password;
}
// if username changed check if taken
if (params.username !== user.username && users.find(x => x.username === params.username)) {
return error('Username "' + params.username + '" is already taken')
}
// update and save user
Object.assign(user, params);
localStorage.setItem(usersKey, JSON.stringify(users));
return ok();
}
function deleteUser() {
if (!isAuthenticated()) return unauthorized();
users = users.filter(x => x.id !== idFromUrl());
localStorage.setItem(usersKey, JSON.stringify(users));
return ok();
}
// helper functions
function ok(body) {
resolve({ ok: true, ...headers(), json: () => Promise.resolve(body) })
}
function unauthorized() {
resolve({ status: 401, ...headers(), json: () => Promise.resolve({ message: 'Unauthorized' }) })
}
function error(message) {
resolve({ status: 400, ...headers(), json: () => Promise.resolve({ message }) })
}
function basicDetails(user) {
const { id, username, firstName, lastName } = user;
return { id, username, firstName, lastName };
}
function isAuthenticated() {
return opts.headers['Authorization'] === 'Bearer fake-jwt-token';
}
function body() {
return opts.body && JSON.parse(opts.body);
}
function idFromUrl() {
const urlParts = url.split('/');
return parseInt(urlParts[urlParts.length - 1]);
}
function headers() {
return {
headers: {
get(key) {
return ['application/json'];
}
}
}
}
});
}
}
+56
View File
@@ -0,0 +1,56 @@
import { useAuthStore } from '@/stores';
export const fetchWrapper = {
get: request('GET'),
post: request('POST'),
put: request('PUT'),
delete: request('DELETE')
};
function request(method) {
return (url, body) => {
const requestOptions = {
method,
headers: authHeader(url)
};
if (body) {
requestOptions.headers['Content-Type'] = 'application/json';
requestOptions.body = JSON.stringify(body);
}
return fetch(url, requestOptions).then(handleResponse);
}
}
// helper functions
function authHeader(url) {
// return auth header with jwt if user is logged in and request is to the api url
const { user } = useAuthStore();
const isLoggedIn = !!user?.token;
const isApiUrl = url.startsWith(import.meta.env.VITE_API_URL);
if (isLoggedIn && isApiUrl) {
return { Authorization: `Bearer ${user.token}` };
} else {
return {};
}
}
async function handleResponse(response) {
const isJson = response.headers?.get('content-type')?.includes('application/json');
const data = isJson ? await response.json() : null;
// check for error response
if (!response.ok) {
const { user, logout } = useAuthStore();
if ([401, 403].includes(response.status) && user) {
// auto logout if 401 Unauthorized or 403 Forbidden response returned from api
logout();
}
// get error message from body or default to response status
const error = (data && data.message) || response.status;
return Promise.reject(error);
}
return data;
}
+2
View File
@@ -0,0 +1,2 @@
export * from './fake-backend';
export * from './fetch-wrapper';
+15 -13
View File
@@ -1,14 +1,16 @@
import { createApp } from "vue"; import { createApp } from 'vue';
import "./style.css"; import { createPinia } from 'pinia';
import App from "./App.vue";
import router from "./router";
import store from "./store";
import "bootstrap";
import "bootstrap/dist/css/bootstrap.min.css";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
createApp(App) import App from './App.vue';
.use(router) import { router } from './router';
.use(store)
.component("font-awesome-icon", FontAwesomeIcon) // setup fake backend
.mount("#app"); import { fakeBackend } from './helpers';
fakeBackend();
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');
-60
View File
@@ -1,60 +0,0 @@
import { createWebHistory, createRouter } from "vue-router";
import Home from "./components/Home.vue";
import Login from "./components/Login.vue";
import Register from "./components/Register.vue";
// lazy-loaded
const Profile = () => import("./components/Profile.vue");
const BoardAdmin = () => import("./components/BoardAdmin.vue");
const BoardModerator = () => import("./components/BoardModerator.vue");
const BoardUser = () => import("./components/BoardUser.vue");
const routes = [
{
path: "/",
name: "home",
component: Home,
},
{
path: "/home",
component: Home,
},
{
path: "/login",
component: Login,
},
{
path: "/register",
component: Register,
},
{
path: "/profile",
name: "profile",
// lazy-loaded
component: Profile,
},
{
path: "/admin",
name: "admin",
// lazy-loaded
component: BoardAdmin,
},
{
path: "/mod",
name: "moderator",
// lazy-loaded
component: BoardModerator,
},
{
path: "/user",
name: "user",
// lazy-loaded
component: BoardUser,
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;
+11
View File
@@ -0,0 +1,11 @@
import { Layout, Login, Register } from '@/views/account';
export default {
path: '/account',
component: Layout,
children: [
{ path: '', redirect: 'login' },
{ path: 'login', component: Login },
{ path: 'register', component: Register }
]
};
+34
View File
@@ -0,0 +1,34 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore, useAlertStore } from '@/stores';
import { Home } from '@/views';
import accountRoutes from './account.routes';
import usersRoutes from './users.routes';
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
linkActiveClass: 'active',
routes: [
{ path: '/', component: Home },
{ ...accountRoutes },
{ ...usersRoutes },
// catch all redirect to home page
{ path: '/:pathMatch(.*)*', redirect: '/' }
]
});
router.beforeEach(async (to) => {
// clear alert on route change
const alertStore = useAlertStore();
alertStore.clear();
// redirect to login page if not logged in and trying to access a restricted page
const publicPages = ['/account/login', '/account/register'];
const authRequired = !publicPages.includes(to.path);
const authStore = useAuthStore();
if (authRequired && !authStore.user) {
authStore.returnUrl = to.fullPath;
return '/account/login';
}
});
+11
View File
@@ -0,0 +1,11 @@
import { Layout, List, AddEdit } from '@/views/users';
export default {
path: '/users',
component: Layout,
children: [
{ path: '', component: List },
{ path: 'add', component: AddEdit },
{ path: 'edit/:id', component: AddEdit }
]
};
-9
View File
@@ -1,9 +0,0 @@
export default function authHeader() {
let user = JSON.parse(localStorage.getItem("user"));
if (user && user.accessToken) {
return { Authorization: "Bearer " + user.accessToken };
} else {
return {};
}
}
-33
View File
@@ -1,33 +0,0 @@
import axios from "axios";
const API_URL = "http://localhost:8200/";
class AuthService {
login(user) {
return axios
.post(API_URL + "api/login/token", {
username: user.username,
password: user.password,
})
.then((response) => {
if (response.data.access_token) {
localStorage.setItem("user", JSON.stringify(response.data));
}
return response.data;
});
}
logout() {
localStorage.removeItem("user");
}
register(user) {
return axios.post(API_URL + "signup", {
username: user.username,
email: user.email,
password: user.password,
});
}
}
export default new AuthService();
-24
View File
@@ -1,24 +0,0 @@
import axios from "axios";
import authHeader from "./auth-header";
const API_URL = "http://localhost:8200/api/user/";
class UserService {
getPublicContent() {
return axios.get(API_URL + "profiles");
}
getUserBoard() {
return axios.get(API_URL + "user", { headers: authHeader() });
}
getModeratorBoard() {
return axios.get(API_URL + "mod", { headers: authHeader() });
}
getAdminBoard() {
return axios.get(API_URL + "admin", { headers: authHeader() });
}
}
export default new UserService();
-61
View File
@@ -1,61 +0,0 @@
import AuthService from "../services/auth.service";
const user = JSON.parse(localStorage.getItem("user"));
const initialState = user
? { status: { loggedIn: true }, user }
: { status: { loggedIn: false }, user: null };
export const auth = {
namespaced: true,
state: initialState,
actions: {
login({ commit }, user) {
return AuthService.login(user).then(
(user) => {
commit("loginSuccess", user);
return Promise.resolve(user);
},
(error) => {
commit("loginFailure");
return Promise.reject(error);
}
);
},
logout({ commit }) {
AuthService.logout();
commit("logout");
},
register({ commit }, user) {
return AuthService.register(user).then(
(response) => {
commit("registerSuccess");
return Promise.resolve(response.data);
},
(error) => {
commit("registerFailure");
return Promise.reject(error);
}
);
},
},
mutations: {
loginSuccess(state, user) {
state.status.loggedIn = true;
state.user = user;
},
loginFailure(state) {
state.status.loggedIn = false;
state.user = null;
},
logout(state) {
state.status.loggedIn = false;
state.user = null;
},
registerSuccess(state) {
state.status.loggedIn = false;
},
registerFailure(state) {
state.status.loggedIn = false;
},
},
};
-10
View File
@@ -1,10 +0,0 @@
import { createStore } from "vuex";
import { auth } from "./auth.module";
const store = createStore({
modules: {
auth,
},
});
export default store;
+19
View File
@@ -0,0 +1,19 @@
import { defineStore } from 'pinia';
export const useAlertStore = defineStore({
id: 'alert',
state: () => ({
alert: null
}),
actions: {
success(message) {
this.alert = { message, type: 'alert-success' };
},
error(message) {
this.alert = { message, type: 'alert-danger' };
},
clear() {
this.alert = null;
}
}
});
+40
View File
@@ -0,0 +1,40 @@
import { defineStore } from 'pinia';
import { fetchWrapper } from '@/helpers';
import { router } from '@/router';
import { useAlertStore } from '@/stores';
const baseUrl = `${import.meta.env.VITE_API_URL}/users`;
export const useAuthStore = defineStore({
id: 'auth',
state: () => ({
// initialize state from local storage to enable user to stay logged in
user: JSON.parse(localStorage.getItem('user')),
returnUrl: null
}),
actions: {
async login(username, password) {
try {
const user = await fetchWrapper.post(`${baseUrl}/authenticate`, { username, password });
// update pinia state
this.user = user;
// store user details and jwt in local storage to keep user logged in between page refreshes
localStorage.setItem('user', JSON.stringify(user));
// redirect to previous url or default to home page
router.push(this.returnUrl || '/');
} catch (error) {
const alertStore = useAlertStore();
alertStore.error(error);
}
},
logout() {
this.user = null;
localStorage.removeItem('user');
router.push('/account/login');
}
}
});
+3
View File
@@ -0,0 +1,3 @@
export * from './alert.store';
export * from './auth.store';
export * from './users.store';
+64
View File
@@ -0,0 +1,64 @@
import { defineStore } from 'pinia';
import { fetchWrapper } from '@/helpers';
import { useAuthStore } from '@/stores';
const baseUrl = `${import.meta.env.VITE_API_URL}/users`;
export const useUsersStore = defineStore({
id: 'users',
state: () => ({
users: {},
user: {}
}),
actions: {
async register(user) {
await fetchWrapper.post(`${baseUrl}/register`, user);
},
async getAll() {
this.users = { loading: true };
try {
this.users = await fetchWrapper.get(baseUrl);
} catch (error) {
this.users = { error };
}
},
async getById(id) {
this.user = { loading: true };
try {
this.user = await fetchWrapper.get(`${baseUrl}/${id}`);
} catch (error) {
this.user = { error };
}
},
async update(id, params) {
await fetchWrapper.put(`${baseUrl}/${id}`, params);
// update stored user if the logged in user updated their own record
const authStore = useAuthStore();
if (id === authStore.user.id) {
// update local storage
const user = { ...authStore.user, ...params };
localStorage.setItem('user', JSON.stringify(user));
// update auth user in pinia state
authStore.user = user;
}
},
async delete(id) {
// add isDeleting prop to user being deleted
this.users.find(x => x.id === id).isDeleting = true;
await fetchWrapper.delete(`${baseUrl}/${id}`);
// remove user from list after deleted
this.users = this.users.filter(x => x.id !== id);
// auto logout if the logged in user deleted their own record
const authStore = useAuthStore();
if (id === authStore.user.id) {
authStore.logout();
}
}
}
});
-296
View File
@@ -1,296 +0,0 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+16
View File
@@ -0,0 +1,16 @@
<script setup>
import { storeToRefs } from 'pinia';
import { useAuthStore } from '@/stores';
const authStore = useAuthStore();
const { user } = storeToRefs(authStore);
</script>
<template>
<div v-if="user">
<h1>Hi {{user.firstName}}!</h1>
<p>You're logged in with Vue 3 + Pinia & JWT!!</p>
<p><router-link to="/users">Manage Users</router-link></p>
</div>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script setup>
import { useAuthStore } from '@/stores';
import { router } from '@/router';
// redirect home if already logged in
const authStore = useAuthStore();
if (authStore.user) {
router.push('/');
}
</script>
<template>
<div class="container">
<div class="row">
<div class="col-sm-8 offset-sm-2 mt-5">
<router-view />
</div>
</div>
</div>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup>
import { Form, Field } from 'vee-validate';
import * as Yup from 'yup';
import { useAuthStore } from '@/stores';
const schema = Yup.object().shape({
username: Yup.string().required('Username is required'),
password: Yup.string().required('Password is required')
});
async function onSubmit(values) {
const authStore = useAuthStore();
const { username, password } = values;
await authStore.login(username, password);
}
</script>
<template>
<div class="card m-3">
<h4 class="card-header">Login</h4>
<div class="card-body">
<Form @submit="onSubmit" :validation-schema="schema" v-slot="{ errors, isSubmitting }">
<div class="form-group">
<label>Username</label>
<Field name="username" type="text" class="form-control" :class="{ 'is-invalid': errors.username }" />
<div class="invalid-feedback">{{ errors.username }}</div>
</div>
<div class="form-group">
<label>Password</label>
<Field name="password" type="password" class="form-control" :class="{ 'is-invalid': errors.password }" />
<div class="invalid-feedback">{{ errors.password }}</div>
</div>
<div class="form-group">
<button class="btn btn-primary" :disabled="isSubmitting">
<span v-show="isSubmitting" class="spinner-border spinner-border-sm mr-1"></span>
Login
</button>
<router-link to="register" class="btn btn-link">Register</router-link>
</div>
</Form>
</div>
</div>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup>
import { Form, Field } from 'vee-validate';
import * as Yup from 'yup';
import { useUsersStore, useAlertStore } from '@/stores';
import { router } from '@/router';
const schema = Yup.object().shape({
firstName: Yup.string()
.required('First Name is required'),
lastName: Yup.string()
.required('Last Name is required'),
username: Yup.string()
.required('Username is required'),
password: Yup.string()
.required('Password is required')
.min(6, 'Password must be at least 6 characters')
});
async function onSubmit(values) {
const usersStore = useUsersStore();
const alertStore = useAlertStore();
try {
await usersStore.register(values);
await router.push('/account/login');
alertStore.success('Registration successful');
} catch (error) {
alertStore.error(error);
}
}
</script>
<template>
<div class="card m-3">
<h4 class="card-header">Register</h4>
<div class="card-body">
<Form @submit="onSubmit" :validation-schema="schema" v-slot="{ errors, isSubmitting }">
<div class="form-group">
<label>First Name</label>
<Field name="firstName" type="text" class="form-control" :class="{ 'is-invalid': errors.firstName }" />
<div class="invalid-feedback">{{ errors.firstName }}</div>
</div>
<div class="form-group">
<label>Last Name</label>
<Field name="lastName" type="text" class="form-control" :class="{ 'is-invalid': errors.lastName }" />
<div class="invalid-feedback">{{ errors.lastName }}</div>
</div>
<div class="form-group">
<label>Username</label>
<Field name="username" type="text" class="form-control" :class="{ 'is-invalid': errors.username }" />
<div class="invalid-feedback">{{ errors.username }}</div>
</div>
<div class="form-group">
<label>Password</label>
<Field name="password" type="password" class="form-control" :class="{ 'is-invalid': errors.password }" />
<div class="invalid-feedback">{{ errors.password }}</div>
</div>
<div class="form-group">
<button class="btn btn-primary" :disabled="isSubmitting">
<span v-show="isSubmitting" class="spinner-border spinner-border-sm mr-1"></span>
Register
</button>
<router-link to="login" class="btn btn-link">Cancel</router-link>
</div>
</Form>
</div>
</div>
</template>
+3
View File
@@ -0,0 +1,3 @@
export { default as Layout } from './Layout.vue';
export { default as Login } from './Login.vue';
export { default as Register } from './Register.vue';

Some files were not shown because too many files have changed in this diff Show More