Compare commits

..

No commits in common. "main" and "dev" have entirely different histories.
main ... dev

82 changed files with 502 additions and 2586 deletions

BIN
.coverage

Binary file not shown.

View file

@ -4,8 +4,6 @@ on:
push:
branches:
- main
paths:
- "blog/**"
jobs:
build_and_deploy:

View file

@ -1,63 +0,0 @@
name: Build and Deploy Backend
on:
push:
branches:
- main
paths:
- "backend/**"
- "pyproject.toml"
- "uv.lock"
- ".forgejo/workflows/deploy_backend.yml"
jobs:
build_and_deploy:
runs-on: roadrunner
container:
volumes:
- /opt/blauwasser-api:/opt/blauwasser-api/
steps:
- name: Deployment-Werkzeuge installieren
run: |
apt-get update
apt-get install -y --no-install-recommends rsync curl
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/uv" --version
- name: Repository auschecken
uses: actions/checkout@v4
# - name: Backend testen
# working-directory: backend
# run: |
# python3 -m pip install --upgrade pip
# python3 -m pip install -e .
# python3 -m pytest
- name: Backend kopieren
run: |
rsync -a --no-perms --no-owner --no-group --delete \
--exclude '.venv' \
--exclude '__pycache__' \
--exclude '*.db' \
backend/ /opt/blauwasser-api/backend/
- name: Abhängigkeiten aktualisieren
run: |
cd /opt/blauwasser-api/backend
uv sync --frozen
- name: Backend neu starten
run: |
systemctl restart blauwasser-api
systemctl is-active --quiet blauwasser-api
- name: Deployment prüfen
run: |
curl --fail https://api.blauwasser-mausis.de/health

4
.gitignore vendored
View file

@ -1 +1,3 @@
node_modules
node_modules
backend/venv/lib/**
backend/venv/lib64/*

View file

@ -2,16 +2,9 @@
"cSpell.language": "en,de-DE",
"cSpell.words": [
"Chiara",
"dotenv",
"Hobbithöhle",
"mailtrap",
"Masala",
"Pomorie",
"Reinke"
],
"python.testing.pytestArgs": [
"backend"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
]
}

3
backend/.gitignore vendored
View file

@ -1,3 +0,0 @@
.venv/lib/**
.venv/lib64/*
dev.db

View file

@ -1 +0,0 @@
3.14

View file

@ -1,5 +0,0 @@
Start Backend
```
uv run fastapi dev
```

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

21
backend/main.py Normal file
View file

@ -0,0 +1,21 @@
from nexios import NexiosApp
import uvicorn
# Create a new Nexios application
app = NexiosApp()
# Define a simple route
@app.get("/")
async def hello_world(request, response):
return response.json({
"message": "Hello from Nexios!",
"status": "success"
})
# If you forget to use async def for your handler, Nexios will run the handler in a threadpool
# If you define two routes with the same path and method, Nexios will raise a conflict error at startup.
# Run the application
if __name__ == "__main__":
uvicorn.run("main:app")

View file

@ -1,31 +0,0 @@
[project]
name = "backend"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "ebby", email = "ebbyplay@proton.me" }
]
requires-python = ">=3.12"
dependencies = [
"fastapi[standard]==0.136.3",
"httpx2>=2.12.0",
"pytest>=9.1.1",
"sqlmodel>=0.0.42",
"mailtrap==2.10.0",
"fastapi-limiter==0.2.0",
"fastapi-crons==2.5.0",
"feedparser==6.0.14",
"PyJWT==2.15.0",
"python-dotenv>=1.0.0",
]
[project.scripts]
backend = "backend:main"
[build-system]
requires = ["uv_build>=0.12.9,<0.13.0"]
build-backend = "uv_build"
[tool.fastapi]
entrypoint = "backend.main:app"

View file

@ -1,5 +0,0 @@
[tool.pyrefly]
project-includes = [
"./**/**/*.py",
]

View file

@ -1,6 +0,0 @@
BACKEND_URL="http://localhost:8000"
FRONTEND_URL="http://localhost:4321"
JWT_SECRET="IamALocalSecretAndMustBeReplacedInProduction"
ENVIRONMENT="local"
MAILTRAP_API_TOKEN="FakedyFake"
DATABASE_URL="sqlite:///dev.db"

View file

@ -1,8 +0,0 @@
import os
ENVIRONMENT = os.environ["ENVIRONMENT"]
JWT_SECRET = os.environ["JWT_SECRET"]
FRONTEND_URL = os.environ["FRONTEND_URL"]
BACKEND_URL = os.environ["BACKEND_URL"]
MAILTRAP_API_TOKEN = os.environ["MAILTRAP_API_TOKEN"]
DATABASE_URL = os.environ["DATABASE_URL"]

View file

@ -1,13 +0,0 @@
from typing import Annotated
from fastapi import Header, HTTPException
async def get_token_header(x_token: Annotated[str, Header()]):
if x_token != "fake-super-secret-token":
raise HTTPException(status_code=400, detail="X-Token header invalid")
async def get_query_token(token: str):
if token != "jessica":
raise HTTPException(status_code=400, detail="No Jessica token provided")

View file

@ -1,51 +0,0 @@
from backend.config import JWT_SECRET
from fastapi.security import HTTPBasicCredentials
from fastapi import Security
from fastapi.security import HTTPBearer
from fastapi import Header
from jwt.exceptions import PyJWTError
from datetime import timezone
import jwt
from sqlmodel import select
from backend.models.user import User
from backend.internal.database import get_session
from sqlmodel import Session
from fastapi import HTTPException
from fastapi import Depends
from datetime import datetime
from datetime import timedelta
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 180
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, JWT_SECRET, algorithm=ALGORITHM)
security = HTTPBearer()
def get_token(credentials = Security(security)) -> str:
parts = credentials.credentials.split()
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1]
raise HTTPException(status_code=403, detail="Invalid authorization format")
def decode_token(token: str)-> str | None:
payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
return payload.get("sub")
async def get_current_user(token: str = Depends(get_token), session: Session = Depends(get_session)):
try:
user_code: str | None = decode_token(token)
if user_code is None:
raise HTTPException(status_code=401, detail="Invalid token")
except PyJWTError:
raise HTTPException(status_code=401, detail="Invalid token")
user = session.exec(select(User).where(User.user_code == user_code)).first()
if user is None:
raise HTTPException(status_code=401, detail="User not found")
return user

View file

@ -1,16 +0,0 @@
from backend.main import app
from fastapi.middleware.cors import CORSMiddleware
origins = [
"http://localhost",
]
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://blauwasser-mausis.de",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

View file

@ -1,17 +0,0 @@
from backend.config import DATABASE_URL
from collections.abc import Generator
from sqlmodel import Session
from sqlmodel import SQLModel
from sqlmodel import create_engine
sqlite_url = DATABASE_URL
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session

View file

@ -1,63 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Newsletter</title>
</head>
<body>
<div>
<h1>Neuer Artikel:</h1>
<h3>{{data.title}}</h3>
<h4>{{data.subtitle}}</h4>
<p>{{data.description}}</p>
<a href={{data.link}}>
<button>Zum Artikel</button>
</a>
<a href="https://blauwasser-mausis.de/blog/bootsuche-england">
<span>Abbestellen</span>
</a>
<a id="admin" href="https://backend.blauwasser-mausis.de/?secret={{data.secret}}">Approve</a>
</div>
</body>
</html>
<style>
* {
font-family: Arial, Helvetica, sans-serif;
}
body {
margin: 32px;
}
div {
display: flex;
align-items: center;
flex-direction: column;
background-color: lightgrey;
padding: 32px 64px;
border-radius: 3px;
}
h3,
h4 {
margin: 0;
}
button {
padding: 12px;
background-color: azure;
border-radius: 3px;
border: none;
}
#admin {
margin-top: 64px;
color: red;
}
</style>

View file

@ -1,56 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Newsletter</title>
</head>
<body>
<div>
<h1>Neuer Artikel:</h1>
<h3>{{data.title}}</h3>
<h4>{{data.subtitle}}</h4>
<p>{{data.description}}</p>
<a href={{data.link}}>
<button>Zum Artikel</button>
</a>
<a href="https://blauwasser-mausis.de/blog/bootsuche-england">
<span>Abbestellen</span>
</a>
</div>
</body>
</html>
<style>
* {
font-family: Arial, Helvetica, sans-serif;
}
body {
margin: 32px;
}
div {
display: flex;
align-items: center;
flex-direction: column;
background-color: lightgrey;
padding: 32px 64px;
border-radius: 3px;
}
h3,
h4 {
margin: 0;
}
button {
padding: 12px;
background-color: azure;
border-radius: 3px;
border: none;
}
</style>

View file

@ -1,40 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Newsletter</title>
</head>
<body>
<div>
<h1>Willkommen, {{data.name}}</h1>
<p>Du hast dich bei Blauwasser-Mausis.de angemeldet. Solltest du das nicht gewesen sein, dann lösche die Daten einfach über den untenstehenden Link.</p>
<p>Dein persönlicher Code, um alle Funktionen nutzen zu können: {{data.user_code}}</p>
<a href="https://blauwasser-mausis.de/">
<span>Daten löschen</span>
</a>
</div>
</body>
</html>
<style>
* {
font-family: Arial, Helvetica, sans-serif;
}
body {
margin: 32px;
}
div {
display: flex;
align-items: center;
flex-direction: column;
background-color: lightgrey;
padding: 32px 64px;
border-radius: 3px;
}
</style>

View file

@ -1,68 +0,0 @@
from backend.config import MAILTRAP_API_TOKEN
from mailtrap import BatchEmailRequest
from mailtrap import BatchSendEmailParams
from backend.config import ENVIRONMENT
from mailtrap import Mail
import mailtrap as mt
from jinja2 import Environment, FileSystemLoader
class ArticleData():
def __init__(self, title, subtitle, description, link):
self.title=title
self.subtitle=subtitle
self.description=description
self.link=link
title: str
subtitle: str
description: str
link: str
class AdminApprovalData(ArticleData):
def __init__(self,title, subtitle, description, link, secret):
ArticleData.__init__(self, title, subtitle, description, link)
self.secret = secret
secret: str
def create_html_from_template(template_name: str, data):
env = Environment(loader = FileSystemLoader('src/backend/internal/jinja_templates'))
template = env.get_template(f"{template_name}.jinja")
return template.render(data=data)
def send_newsletter_admin_approval(data: ArticleData ):
html = create_html_from_template("approval", data)
mail = mt.Mail(
sender=mt.Address(email="newsletter@blauwasser-mausis.de", name="Flaschenpost"),
to=[mt.Address(email="blauwasser-mausis@proton.me")],
subject=f"[Approval] {data.title}",
html=html,
)
send_mail(mail)
def send_mail(mail: Mail):
if ENVIRONMENT == "local":
print(f"Mail sent: {mail.subject}")
print(mail.html)
else:
client = mt.MailtrapClient(token=MAILTRAP_API_TOKEN)
client.send(mail)
def send_batch_mail(mails: list[BatchEmailRequest]):
if ENVIRONMENT == "local":
for mail in mails:
print(f"Mail sent: {mail.subject} to {mail.to}")
else:
client = mt.MailtrapClient(token=MAILTRAP_API_TOKEN)
params = BatchSendEmailParams(
base={
"from": {
"name": "Blauwasser Mausis",
"email": "newsletter@blauwasser-mausis.de"
},
"subject": "Flaschenpost | Blauwasser Mausis",
},
requests=mails
)
client.batch_send(params)

View file

@ -1,32 +0,0 @@
from feedparser import FeedParserDict
from backend.config import FRONTEND_URL
from backend.internal.authentication import create_access_token
from backend.internal.mail_util import AdminApprovalData
from backend.internal.mail_util import send_newsletter_admin_approval
from datetime import timedelta
from datetime import datetime
import feedparser
def check_blog_rss_and_send_admin_mail():
feed = feedparser.parse(f"{FRONTEND_URL}/rss.xml")
for article in feed.entries:
date_string = str(article["published"])
date_object = datetime.strptime(date_string, '%a, %d %b %Y %H:%M:%S %Z')
if date_object > (datetime.now() - timedelta(days=16)):
title = str(article["title"])
description = str(article["summary"])
subtitle = str(article["subtitle"])
link = str(article["link"])
secret = create_access_token({"sub": title})
data = AdminApprovalData(title, subtitle, description, link, secret)
send_newsletter_admin_approval(data)
def find_article_by_title(title: str) -> FeedParserDict | None:
feed = feedparser.parse(f"{FRONTEND_URL}/rss.xml")
for article in feed.entries:
if title == str(article["title"]):
return article
return None

View file

@ -1,5 +0,0 @@
import string
import random
def generate_user_code() -> str:
return ''.join(random.choices(string.ascii_uppercase, k=4))

View file

@ -1,31 +0,0 @@
from dotenv.main import load_dotenv
load_dotenv()
from .routers import users, comments, newsletters, health
from backend.internal.database import create_db_and_tables
from fastapi import FastAPI
from fastapi_crons import Crons
from backend.internal.rss_reader import check_blog_rss_and_send_admin_mail
# TODO: TLS
# TODO: Deploy
app = FastAPI()
crons = Crons(app)
@crons.cron("0 14 * * *", name="RSS-Check")
async def check_for_new_article():
check_blog_rss_and_send_admin_mail()
@app.on_event("startup")
def on_startup():
create_db_and_tables()
app.include_router(users.router)
app.include_router(comments.router)
app.include_router(newsletters.router)
app.include_router(health.router)

View file

@ -1,2 +0,0 @@
from .user import User
from .comment import Comment

View file

@ -1,22 +0,0 @@
from dataclasses import dataclass
from datetime import datetime
import uuid
from typing import TYPE_CHECKING
from sqlmodel import Relationship
from sqlmodel import Field
from sqlmodel import SQLModel
if TYPE_CHECKING:
from backend.models.user import User
class CommentBase(SQLModel):
pass
@dataclass
class Comment(CommentBase, table=True):
id: uuid.UUID | None = Field(default_factory=uuid.uuid7, primary_key=True)
slug: str = Field(index=True)
content: str = Field()
date_time: datetime = Field(default_factory=datetime.now)
user_id: uuid.UUID = Field(foreign_key="user.id")
user: "User" = Relationship(back_populates="comments")

View file

@ -1,5 +0,0 @@
# Reaction: slug and visitor_hash[(UUID or IP from Header) and salt] (foreign_key)
#Reference: https://isaacfei.com/posts/simple-like-system/

View file

@ -1,63 +0,0 @@
from sqlmodel._compat import ConfigDict
from uuid import uuid7
from datetime import datetime
from backend.internal.user_code_gen import generate_user_code
import uuid
from typing import TYPE_CHECKING
from sqlmodel import Field
from sqlmodel import Relationship
from sqlmodel import SQLModel
if TYPE_CHECKING:
from backend.models.comment import Comment
class UserBase(SQLModel):
pass
class User(UserBase, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid7, primary_key=True)
email: str = Field(unique=True)
name: str = Field(unique=True)
user_code: str = Field(default_factory=generate_user_code,unique=True)
subscription: bool = Field()
created_at: datetime = Field(default_factory=datetime.now)
comments: list["Comment"] = Relationship(back_populates="user")
@classmethod
def factory(cls,
id: uuid.UUID | None = None,
email: str = "Email@Test.mail",
name: str = "TestName",
user_code: str = "WYZZ",
subscription: bool = False,
) -> "User":
return cls(
id=id or uuid.uuid7(),
email=email,
name=name,
user_code=user_code,
subscription=subscription,
)
class NewUser(UserBase):
email: str
name: str
subscription: bool
@classmethod
def factory(cls,
email: str = "Email@Test.mail",
name: str = "TestName",
subscription: bool = False
) -> "NewUser":
return cls(
email = email,
name = name,
subscription = subscription
)
class UserInfo(UserBase):
name: str
email: str
subscription: bool

View file

@ -1,18 +0,0 @@
# add comment
# delete comment
# get comments
from backend.internal.database import get_session
from fastapi import Depends
from sqlmodel import Session
from fastapi import APIRouter
from ..models.comment import Comment
router = APIRouter(
prefix="/comments",
tags=["comments"],
dependencies=[],
responses={404: {"description": "Not found"}},
)

View file

@ -1,8 +0,0 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health", include_in_schema=False)
def health():
return {"status": "ok"}

View file

@ -1,44 +0,0 @@
from mailtrap import Address
from backend.internal.mail_util import create_html_from_template
from mailtrap import BatchEmailRequest
from mailtrap import BatchMail
from backend.internal.mail_util import send_batch_mail
from starlette.status import HTTP_400_BAD_REQUEST
from fastapi import HTTPException
from sqlmodel import select
from backend.models.user import User
from sqlmodel import Session
from backend.internal.database import get_session
from fastapi import Depends
from backend.internal.rss_reader import find_article_by_title
from backend.internal.authentication import decode_token
from fastapi import Form
from fastapi import APIRouter
router = APIRouter(
prefix="/newsletter",
dependencies=[],
responses={404: {"description": "Not found"}},
)
@router.post(
path="/approve",
status_code=201
)
def approve_newsletter(secret: str = Form(), session: Session = Depends(get_session)):
title = decode_token(secret)
if title is not None:
article = find_article_by_title(title)
if article is not None:
subscribed_users = session.exec(select(User).where(User.subscription == True))
mails: list[BatchEmailRequest] = []
for user in subscribed_users:
html = create_html_from_template("newsletter", user)
mail = BatchEmailRequest(to=[Address(email=user.email,name=user.name)],html=html)
mails.append(mail)
send_batch_mail(mails)
else:
raise HTTPException(HTTP_400_BAD_REQUEST)

View file

@ -1,5 +0,0 @@
# addReaction
# delete Reaction
# get Reactions by slug

View file

@ -1,102 +0,0 @@
from mailtrap import Address
from backend.internal.mail_util import send_mail
from backend.internal.mail_util import create_html_from_template
from mailtrap import Mail
from pyrate_limiter import Duration
from pyrate_limiter import Rate
from pyrate_limiter import Limiter
from backend.models.user import UserInfo
from backend.internal.authentication import get_current_user
from backend.internal.authentication import create_access_token
from starlette.status import HTTP_400_BAD_REQUEST,HTTP_401_UNAUTHORIZED
from fastapi import Form
from sqlmodel import select
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from backend.models.user import User
from backend.models.user import NewUser
from backend.internal.database import get_session
from fastapi import Depends
from sqlmodel import Session
from fastapi import APIRouter
from fastapi_limiter.depends import RateLimiter
router = APIRouter(
prefix="/users",
dependencies=[],
responses={404: {"description": "Not found"}},
)
@router.post(
path="/",
status_code=201,
dependencies=[Depends(RateLimiter(limiter=Limiter(Rate(1, Duration.MINUTE))))]
)
def register_user(new_user: NewUser, session: Session = Depends(get_session)):
user = User.model_validate(new_user)
try:
session.add(user)
session.commit()
session.refresh(user)
mail = Mail(
to=[Address(email=user.email,name=user.name)],
sender=Address(email="noreply@blauwasser-mausis.de",name="Blauwasser Mausis"),
subject=f"[Registrierung] Willkommen {user.name} | Blauwasser Mausis",
html=create_html_from_template("welcome", user)
)
send_mail(mail)
except IntegrityError as e:
session.rollback()
raise HTTPException(HTTP_400_BAD_REQUEST)
@router.post(
path="/login",
status_code=201,
dependencies=[Depends(RateLimiter(limiter=Limiter(Rate(3, Duration.SECOND * 30))))]
)
def login(user_code: str = Form(), session: Session = Depends(get_session)) -> str:
user = session.exec(select(User).where(User.user_code == user_code)).first()
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED)
return create_access_token({"sub": user.user_code})
@router.post(
path="/forgot",
dependencies=[Depends(RateLimiter(limiter=Limiter(Rate(1, Duration.MINUTE))))]
)
def forgot_user_code(email: str = Form(), session: Session = Depends(get_session)):
user = session.exec(select(User).where(User.email == email)).first()
if user is not None:
pass # TODO: send Mail
@router.get(path="/me", status_code=201)
def get_current_user_info(current_user: User = Depends(get_current_user)) -> UserInfo:
return UserInfo.model_validate(current_user)
@router.delete(path="/me", status_code=201)
def delete(current_user: User = Depends(get_current_user), session: Session = Depends(get_session)) :
session.delete(current_user)
session.commit()
@router.put(path="/me/subscribe", status_code=201)
def change_subscription(
subscription: bool = Form(),
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
current_user.subscription = subscription
session.add(current_user)
session.commit()
# TODO:
# change mail <- token, new mail

View file

@ -1,84 +0,0 @@
from backend.models.user import NewUser
from sqlmodel import select
from backend.models.user import User
from backend.internal.database import get_session
from sqlmodel import Session
from sqlmodel import StaticPool
from sqlmodel import create_engine
import pytest
from backend.internal.database import engine
from sqlmodel import SQLModel
from backend.main import app
from fastapi.testclient import TestClient
@pytest.fixture(name="session")
def session_fixture():
engine = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
@pytest.fixture(name="client")
def client_fixture(session: Session):
def get_session_override():
return session
app.dependency_overrides[get_session] = get_session_override
client = TestClient(app)
yield client
app.dependency_overrides.clear()
def test_register_user_saved(session:Session, client: TestClient):
new_user = {
"email": "Test@Test.test",
"name": "Test1",
"subscription": True
}
response = client.post(url="/users/", json=new_user)
assert response.status_code == 201
db_user = session.exec(select(User).where(User.email == "Test@Test.test")).first()
assert db_user
assert db_user.email == "Test@Test.test"
assert db_user.name == "Test1"
assert db_user.subscription == True
def test_email_not_unique_returns_400(session: Session, client: TestClient):
new_user = NewUser.factory(email="NotUnique")
user = User().factory(email="NotUnique")
session.add(user)
session.commit()
response = client.post(url="/users/", json=new_user.model_dump())
assert response.status_code == 400
def test_name_not_unique_returns_400(session: Session, client: TestClient):
new_user = NewUser.factory(email="other@other")
user = User().factory()
session.add(user)
session.commit()
response = client.post(url="/users/", json=new_user.model_dump())
assert response.status_code == 400
def test_login_successful(session: Session, client: TestClient):
user = User().factory()
session.add(user)
session.commit()
session.refresh(user)
payload = {
"user_code": user.user_code
}
response = client.post(
url="/login",
data=payload
)
assert response.status_code == 201
assert response.json()[:2] == "ey"

1694
backend/uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,247 @@
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.
.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.
.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.
.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.
.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.
.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.
.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.
.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
For more information on Execution Policies:
https://go.microsoft.com/fwlink/?LinkID=135170
#>
Param(
[Parameter(Mandatory = $false)]
[String]
$VenvDir,
[Parameter(Mandatory = $false)]
[String]
$Prompt
)
<# Function declarations --------------------------------------------------- #>
<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.
.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.
#>
function global:deactivate ([switch]$NonDestructive) {
# Revert to original values
# The prior prompt:
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
}
# The prior PYTHONHOME:
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
}
# The prior PATH:
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
}
# Just remove the VIRTUAL_ENV altogether:
if (Test-Path -Path Env:VIRTUAL_ENV) {
Remove-Item -Path env:VIRTUAL_ENV
}
# Just remove VIRTUAL_ENV_PROMPT altogether.
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
}
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
}
# Leave deactivate function in the global namespace if requested:
if (-not $NonDestructive) {
Remove-Item -Path function:deactivate
}
}
<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.
If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.
.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
[String]
$ConfigDir
) {
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
# An empty map will be returned if no config file is found.
$pyvenvConfig = @{ }
if ($pyvenvConfigPath) {
Write-Verbose "File exists, parse `key = value` lines"
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
$pyvenvConfigContent | ForEach-Object {
$keyval = $PSItem -split "\s*=\s*", 2
if ($keyval[0] -and $keyval[1]) {
$val = $keyval[1]
# Remove extraneous quotations around a string value.
if ("'""".Contains($val.Substring(0, 1))) {
$val = $val.Substring(1, $val.Length - 2)
}
$pyvenvConfig[$keyval[0]] = $val
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
}
}
}
return $pyvenvConfig
}
<# Begin Activate script --------------------------------------------------- #>
# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
Write-Verbose "VenvDir=$VenvDir"
}
# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
$Prompt = $pyvenvCfg['prompt'];
}
else {
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
$Prompt = Split-Path -Path $venvDir -Leaf
}
}
Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"
# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
Write-Verbose "Setting prompt to '$Prompt'"
# Set the prompt to include the env name
# Make sure _OLD_VIRTUAL_PROMPT is global
function global:_OLD_VIRTUAL_PROMPT { "" }
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
function global:prompt {
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
_OLD_VIRTUAL_PROMPT
}
$env:VIRTUAL_ENV_PROMPT = $Prompt
}
# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
Remove-Item -Path Env:PYTHONHOME
}
# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"

70
backend/venv/bin/activate Normal file
View file

@ -0,0 +1,70 @@
# This file must be used with "source bin/activate" *from bash*
# You cannot run it directly
deactivate () {
# reset old environment variables
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
PATH="${_OLD_VIRTUAL_PATH:-}"
export PATH
unset _OLD_VIRTUAL_PATH
fi
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
export PYTHONHOME
unset _OLD_VIRTUAL_PYTHONHOME
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
PS1="${_OLD_VIRTUAL_PS1:-}"
export PS1
unset _OLD_VIRTUAL_PS1
fi
unset VIRTUAL_ENV
unset VIRTUAL_ENV_PROMPT
if [ ! "${1:-}" = "nondestructive" ] ; then
# Self destruct!
unset -f deactivate
fi
}
# unset irrelevant variables
deactivate nondestructive
# on Windows, a path can contain colons and backslashes and has to be converted:
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
# transform D:\path\to\venv to /d/path/to/venv on MSYS
# and to /cygdrive/d/path/to/venv on Cygwin
export VIRTUAL_ENV=$(cygpath /home/ebby/Desktop/Bluewaterblog/backend/venv)
else
# use the path as-is
export VIRTUAL_ENV=/home/ebby/Desktop/Bluewaterblog/backend/venv
fi
_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/"bin":$PATH"
export PATH
# unset PYTHONHOME if set
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
# could use `if (set -u; : $PYTHONHOME) ;` in bash
if [ -n "${PYTHONHOME:-}" ] ; then
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
unset PYTHONHOME
fi
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1:-}"
PS1='(venv) '"${PS1:-}"
export PS1
VIRTUAL_ENV_PROMPT='(venv) '
export VIRTUAL_ENV_PROMPT
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null

View file

@ -0,0 +1,27 @@
# This file must be used with "source bin/activate.csh" *from csh*.
# You cannot run it directly.
# Created by Davide Di Blasi <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
# Unset irrelevant variables.
deactivate nondestructive
setenv VIRTUAL_ENV /home/ebby/Desktop/Bluewaterblog/backend/venv
set _OLD_VIRTUAL_PATH="$PATH"
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
set _OLD_VIRTUAL_PROMPT="$prompt"
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
set prompt = '(venv) '"$prompt"
setenv VIRTUAL_ENV_PROMPT '(venv) '
endif
alias pydoc python -m pydoc
rehash

View file

@ -0,0 +1,69 @@
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/). You cannot run it directly.
function deactivate -d "Exit virtual environment and return to normal shell environment"
# reset old environment variables
if test -n "$_OLD_VIRTUAL_PATH"
set -gx PATH $_OLD_VIRTUAL_PATH
set -e _OLD_VIRTUAL_PATH
end
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
set -e _OLD_VIRTUAL_PYTHONHOME
end
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
set -e _OLD_FISH_PROMPT_OVERRIDE
# prevents error when using nested fish instances (Issue #93858)
if functions -q _old_fish_prompt
functions -e fish_prompt
functions -c _old_fish_prompt fish_prompt
functions -e _old_fish_prompt
end
end
set -e VIRTUAL_ENV
set -e VIRTUAL_ENV_PROMPT
if test "$argv[1]" != "nondestructive"
# Self-destruct!
functions -e deactivate
end
end
# Unset irrelevant variables.
deactivate nondestructive
set -gx VIRTUAL_ENV /home/ebby/Desktop/Bluewaterblog/backend/venv
set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
# Unset PYTHONHOME if set.
if set -q PYTHONHOME
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
set -e PYTHONHOME
end
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
# fish uses a function instead of an env var to generate the prompt.
# Save the current fish_prompt function as the function _old_fish_prompt.
functions -c fish_prompt _old_fish_prompt
# With the original prompt function renamed, we can override with our own.
function fish_prompt
# Save the return status of the last command.
set -l old_status $status
# Output the venv prompt; color taken from the blue of the Python logo.
printf "%s%s%s" (set_color 4B8BBE) '(venv) ' (set_color normal)
# Restore the return status of the previous command.
echo "exit $old_status" | .
# Output the original/"old" prompt.
_old_fish_prompt
end
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
set -gx VIRTUAL_ENV_PROMPT '(venv) '
end

8
backend/venv/bin/idna Executable file
View file

@ -0,0 +1,8 @@
#!/home/ebby/Desktop/Bluewaterblog/backend/venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from idna.cli import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
backend/venv/bin/nexios Executable file
View file

@ -0,0 +1,8 @@
#!/home/ebby/Desktop/Bluewaterblog/backend/venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from nexios.cli import cli
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli())

8
backend/venv/bin/pip Executable file
View file

@ -0,0 +1,8 @@
#!/home/ebby/Desktop/Bluewaterblog/backend/venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
backend/venv/bin/pip3 Executable file
View file

@ -0,0 +1,8 @@
#!/home/ebby/Desktop/Bluewaterblog/backend/venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
backend/venv/bin/pip3.12 Executable file
View file

@ -0,0 +1,8 @@
#!/home/ebby/Desktop/Bluewaterblog/backend/venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

1
backend/venv/bin/python Symbolic link
View file

@ -0,0 +1 @@
/usr/bin/python

1
backend/venv/bin/python3 Symbolic link
View file

@ -0,0 +1 @@
python

1
backend/venv/bin/python3.12 Symbolic link
View file

@ -0,0 +1 @@
python

8
backend/venv/bin/uvicorn Executable file
View file

@ -0,0 +1,8 @@
#!/home/ebby/Desktop/Bluewaterblog/backend/venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from uvicorn.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

1
backend/venv/lib64 Symbolic link
View file

@ -0,0 +1 @@
lib

5
backend/venv/pyvenv.cfg Normal file
View file

@ -0,0 +1,5 @@
home = /usr/bin
include-system-site-packages = false
version = 3.12.3
executable = /usr/bin/python3.12
command = /usr/bin/python -m venv /home/ebby/Desktop/Bluewaterblog/backend/venv

View file

@ -14,7 +14,7 @@ import BlogLink from '../../components/blog/BlogLink.astro'
import SingleImage from '../../components/blog/SingleImage.astro'
import DoubleImage from '../../components/blog/DoubleImage.astro'
Die titelgebende Frage haben wir in den letzten Tagen unglaublich oft gehört. Deshalb möchten wir euch zeigen, was für uns in nächster Zeit ansteht. Anfangen will ich in der Vergangenheit und enden in der Zukunft. In einem nächsten Artikel kommen wir sicher nochmal 'Zurück in die Zukunft' und beschreiben, was auf den zukünftigen Etappen passiert ist. <br />
Die titelgebende Frage haben wir in den letzten Tagen unglaublich oft gehört. Deshalb möchten wir euch zeigen, was für uns in nächster Zeit ansteht. Anfangen will ich in der Vergangenheit und Enden in der Zukunft. In einem nächsten Artikel kommen wir sicher nochmal 'Zurück in die Zukunft' und beschreiben, was auf den zukünftigen Etappen passiert ist. <br />
Jetzt muss ich aber erst mal los. Das Boarding für den Flug nach Sofia beginnt!
---
@ -91,7 +91,7 @@ Da wir von Nürnberg fliegen, haben wir es uns nicht nehmen lassen das Bahn- und
### Zukunft
Nach Bulgarien, wovon wir natürlich ausführlich berichten werden, geht es weiter nach England. Dort liegt an der Küste von East Essex eine unserer Favoriten. Eine Trintella IV. Um uns vom furchtbaren englischen Wetter zu erholen wird die nächste Station das erholsame Südspanien sein. Jetzt, wo die Tage in Deutschland grauer werden und die Temperaturen auch mal wieder mehr als nur ein dünnes Jäckchen verlangen, fühlt sich eine Verlängerung des Sommers außerordentlich gut an. Schon in Bulgarien blicken wir Temperaturen von durchschnittlich 28°C entgegen.
In Almería werden wir ein ganz besonderes Boot angucken. Es hat einen Elektromotor! Wenn ich an die Vorteile, leise, sauber, wartungsarm, unabhängig, denke schlägt mein Herz \<3 höher. Die Akkukapazität fällt zwar etwas klein aus, aber nichts, was man nicht anpassen könnte. Bei den meisten anderen Booten müssen wir darauf verzichten. Die E-Mobilität ist in der Welt der Segel-Yachten noch nicht etabliert. Schon gar nicht bei denjenigen, die 40 Jahre alt sind...<br />
In Almería werden wir ein ganz besonderes Boot angucken. Es hat einen Elektromotor! Wenn ich an die Vorteile, leise, sauber, wartungsarm, unabhängig, denke schlägt mein Herz \<3 höher. Die Akkukapazität fällt zwar etwas klein aus, aber nichts, was man nicht anpassen könnte. Bei den meisten anderen Booten müssen wir darauf verzichten. Die E-Mobilität ist in der Welt der Seegel-Yachten noch nicht etabliert. Schon gar nicht bei denjenigen, die 40 Jahre alt sind...<br />
Und dann? Gute Frage. Dann entscheiden wir uns für eines der Boote oder planen weiter. Je nachdem wie es läuft. <br />
Anyway, ihr werdet es erfahren. Vielleicht dann sogar schon per Newsletter Erinnerung. Ich arbeite daran :)
@ -102,12 +102,12 @@ Anyway, ihr werdet es erfahren. Vielleicht dann sogar schon per Newsletter Erinn
Immer, wenn wir mit Menschen reden, die wir länger nicht gesehen haben strömen jede Menge Fragen auf uns ein. Dabei haben wir selber so viele dazu, was bei euch los ist! <br />
Die häufigste war "Habt ihr jetzt schon ein Boot?". <br />
Besonders bei unserem Besuch auf der großen Betriebsversammlung unserer (noch) Firma und der Feierabendveranstaltung danach, mussten wir unsere Pläne oft erzählen. Wir haben all die Freunde wiedergetroffen, die wir seid fast zwei Monaten nicht mehr gesehen haben. Es war toll euch alle zu sehen und eure Neuigkeiten zu hören.
Besonders bei unseren Besuch auf der großen Betriebsversammlung unserer (noch) Firma und der Feierabendveranstaltung danach, mussten wir unsere Pläne oft erzählen. Wir haben all die Freunde wiedergetroffen, die wir seid fast zwei Monaten nicht mehr gesehen haben. Es war toll euch alle zu sehen und eure Neuigkeiten zu hören.
Auch ein nachmittäglicher Spaziergang durch Erfurt hat uns überraschend vielen uns bekannten Menschen in die Arme getrieben. Das hat uns sehr euphorisiert. Besonders schön war es mit den Besitzern von <BlogLink text="Allride" url="https://allride.services/" /> (bester Fahrradreparaturladen in mind. Erfurt) einen Plausch zu halten. Die beiden haben ihre Passion gefunden. Das merkt man in allem, was sie tun. Lieben Dank euch, dass ihr uns immer wieder so toll mit unseren Rädern geholfen und beraten habt!
Insgesamt hat uns das Interesse an unseren Fortschritten Richtung Boot überwältigt. Die regen Nachfragen und vor allem, dass so viele unseren Blog lesen. Großes Danke an der Stelle. Es fühlt sich gut an, dass wir es schaffen euch ein kleines Stück mitzunehmen. Wenn irgendwann die Kommentarfunktion fertig ist geht's auch mehr in die andere Richtung. Bald, versprochen :) .
Zu spüren wie unser Vorhaben inspiriert, zeigt uns wieder ein mal: Wir haben alles richtig gemacht. Oder sind zumindest in die richtige Richtung unterwegs. Diese Flucht aus dem Alltag, in ein neues Abenteuer in so einem unbekannten Gebiet ist magisch. Der Vorteil für euch ist, dass ihr größtenteils die romantischen Seiten mitbekommt. Nicht, dass bisher besonders viel schiefgelaufen wäre. Aber zwischen den Highlights liegt auch immer wieder viel Zeit. Wir bemühen uns auch die Anstrengungen und blöden Momente mit einzufangen. Mögen dieser wenige kommen.
Zu spüren wie unser Vorhaben inspiriert, zeigt uns wieder ein mal: Wir haben alles richtig gemacht. Oder sind zumindest in die richtige Richtung unterwegs. Diese Flucht aus dem Alltag, in ein neues Abenteuer in so einem unbekanntem Gebiet ist magisch. Der Vorteil für euch ist, dass ihr größtenteils die romantischen Seiten mitbekommt. Nicht, dass bisher besonders viel schiefgelaufen wäre. Aber zwischen den Highlights liegt auch immer wieder viel Zeit. Wir bemühen uns auch die Anstrengungen und blöden Momente mit einzufangen. Mögen dieser wenige kommen.
Jetzt muss ich aber auch schon wieder den Laptop wegpacken. Wir landen gleich in Sofia.

View file

@ -6,7 +6,7 @@ image:
path: titelbild_bulgarien.webp
alt: Chiara und Tim haben ihre Beine auf einem Steinwall liegen. Im Hintergrund sieht man das Meer.
author: Tim
date: 2026-09-17
date: 2026-09-15
slug: bootsuche-bulgarien
time2read: 12
---

View file

@ -12,17 +12,10 @@ export async function GET(context) {
site: context.site,
items: posts.map(post => ({
title: post.frontmatter.title,
subtitle: post.frontmatter.subtitle,
pubDate: Date.parse(post.frontmatter.date),
description: post.frontmatter.description,
link: `/blog/${post.frontmatter.slug}`,
customData: `
<subtitle>
<![CDATA[${post.frontmatter.subtitle}]]>
</subtitle>
`,
link: `/blog/${post.frontmatter.slug}`
})),
customData: `<language>de-de</language>`,
});