8 Commits
v0.4 ... v0.6

Author SHA1 Message Date
Jack Case
ea843597aa updates 2025-10-30 01:55:18 +00:00
Jack Case
b35c92b96f ran tests against a local sqlite DB
reports appear to work properly now, will run prod DB migrations and upgrade the server container when I'm home.
2025-10-29 15:30:02 +00:00
Jack Case
e420a31127 new pass at properly handling related report models 2025-10-29 14:33:02 +00:00
Jack Case
9c07870cb7 properly specify the Report model with relationships to User and Path 2025-10-29 13:50:52 +00:00
Jack Case
f0bed2783e add info to README 2025-10-27 14:35:31 -04:00
Jack Case
360872fdd0 associate reports with users
this will definitely need some more work.
2025-10-26 16:05:20 +00:00
Jack Case
5840b82bcc add timestamp to report model 2025-10-26 15:32:34 +00:00
Jack Case
f8371f9654 adding user and path association model 2025-10-26 11:26:41 -04:00
8 changed files with 151 additions and 9 deletions

View File

@@ -22,7 +22,7 @@
// Use 'postCreateCommand' to run commands after the container is created. // Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "pip3 install --user -r requirements.txt", // "postCreateCommand": "pip3 install --user -r requirements.txt",
"postCreateCommand": ". ./env/bin/activate; python -m pip install -r requirements.txt" "postCreateCommand": "python -m venv env && . ./env/bin/activate && python -m pip install -r requirements.txt"
// Configure tool-specific properties. // Configure tool-specific properties.
// "customizations": {}, // "customizations": {},

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.devcontainer/
.vscode/
test_db.sqlite
env/
**/__pycache__/
slopserver/alembic/

View File

@@ -1 +1,11 @@
# slop-farmer-server # slop-farmer-server
FastAPI-based server to form the backend of the Slop Farmer browser extension.
Handles user authentication, requests to check search result URLs against reported slop,
and new reports of slop URLs from users.
## Docker Container
run the server with `docker run -P --env-file <env file> slopfarmer`
## Environment Variables
DB_URL - The url connection string to pass to SQLAlchemy
TOKEN_SECRET - the secret used for signing JWTs used for user auth after login

View File

@@ -0,0 +1,47 @@
"""user_path_association
Revision ID: 2eee353294c6
Revises: 12eca4bad288
Create Date: 2025-10-25 23:17:50.734413
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision: str = '2eee353294c6'
down_revision: Union[str, Sequence[str], None] = '12eca4bad288'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('report',
sa.Column('path_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['path_id'], ['path.id'], name=op.f('fk_report_path_id_path')),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_report_user_id_user')),
sa.PrimaryKeyConstraint('path_id', 'user_id', name=op.f('pk_report'))
)
op.alter_column('path', 'domain_id',
existing_type=sa.INTEGER(),
nullable=True)
op.drop_column('user', 'salt')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('salt', sa.VARCHAR(), autoincrement=False, nullable=False))
op.alter_column('path', 'domain_id',
existing_type=sa.INTEGER(),
nullable=False)
op.drop_table('report')
# ### end Alembic commands ###

View File

@@ -0,0 +1,33 @@
"""report table timestamp
Revision ID: b26c87d1f838
Revises: 2eee353294c6
Create Date: 2025-10-26 15:30:51.268150
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision: str = 'b26c87d1f838'
down_revision: Union[str, Sequence[str], None] = '2eee353294c6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('report', sa.Column('timestamp', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('report', 'timestamp')
# ### end Alembic commands ###

View File

@@ -1,9 +1,10 @@
from collections.abc import Iterable from collections.abc import Iterable
from datetime import datetime
from urllib.parse import ParseResult from urllib.parse import ParseResult
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from slopserver.models import Domain, Path, User from slopserver.models import Domain, Path, User, Report
def select_slop(urls: list[ParseResult], engine: Engine) -> Iterable[Domain]: def select_slop(urls: list[ParseResult], engine: Engine) -> Iterable[Domain]:
query = select(Domain).where(Domain.domain_name.in_(url[1] for url in urls)) query = select(Domain).where(Domain.domain_name.in_(url[1] for url in urls))
@@ -11,7 +12,7 @@ def select_slop(urls: list[ParseResult], engine: Engine) -> Iterable[Domain]:
rows = session.scalars(query).all() rows = session.scalars(query).all()
return rows return rows
def insert_slop(urls: list[ParseResult], engine: Engine): def insert_slop(urls: list[ParseResult], engine: Engine, user: User | None = None):
domain_dict: dict[str. set[str]] = dict() domain_dict: dict[str. set[str]] = dict()
for url in urls: for url in urls:
if not domain_dict.get(url[1]): if not domain_dict.get(url[1]):
@@ -33,15 +34,40 @@ def insert_slop(urls: list[ParseResult], engine: Engine):
if not domain in existing_dict: if not domain in existing_dict:
# create a new domain object and paths # create a new domain object and paths
new_domain = Domain(domain_name=domain, paths=list()) new_domain = Domain(domain_name=domain, paths=list())
new_domain.paths = [Path(path=path) for path in paths] new_paths = list()
for path in paths:
new_path = Path(path=path)
if user:
reports = list()
reports.append(Report(path=new_path, user=user, timestamp=datetime.now()))
new_path.reports = reports
new_paths.append(new_path)
new_domain.paths = new_paths
session.add(new_domain) session.add(new_domain)
else: else:
existing_domain = existing_dict[domain] existing_domain = existing_dict[domain]
existing_paths = set((path.path for path in existing_domain.paths)) existing_paths = dict({path.path: path for path in existing_domain.paths})
for path in paths: for path in paths:
if not path in existing_paths: if not path in existing_paths:
existing_domain.paths.append(Path(path=path)) new_path = Path(path=path)
if user:
report_list = list()
report_list.append(Report(path=new_path, user=user, timestamp=datetime.now()))
new_path.reports = report_list
existing_domain.paths.append(new_path)
session.add(new_path)
else:
# domain and path exist, append to the path's reports
if user:
existing_path = existing_paths.get(path)
report_dict = {(report.user.id, report.path.id): report for report in existing_path.reports}
existing_report = report_dict.get((user.id, existing_path.id))
if existing_report:
existing_report.timestamp = datetime.now()
else:
existing_path.reports.append(Report(path=existing_path, user=user, timestamp=datetime.now()))
session.commit() session.commit()

View File

@@ -2,6 +2,8 @@ from typing import Annotated
from sqlmodel import Field, SQLModel, create_engine, Relationship from sqlmodel import Field, SQLModel, create_engine, Relationship
from pydantic import AfterValidator, Base64Str, BaseModel, EmailStr, Json, SecretStr from pydantic import AfterValidator, Base64Str, BaseModel, EmailStr, Json, SecretStr
from datetime import datetime
from altcha import Payload as AltchaPayload, verify_solution from altcha import Payload as AltchaPayload, verify_solution
from urllib.parse import urlparse, ParseResult from urllib.parse import urlparse, ParseResult
@@ -35,6 +37,7 @@ class Path(SQLModel, table=True):
domain_id: int | None = Field(foreign_key="domain.id") domain_id: int | None = Field(foreign_key="domain.id")
domain: Domain = Relationship(back_populates="paths") domain: Domain = Relationship(back_populates="paths")
reports: list["Report"] = Relationship(back_populates="path")
class User(SQLModel, table=True): class User(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
@@ -43,6 +46,16 @@ class User(SQLModel, table=True):
email_verified: bool = Field(default=False) email_verified: bool = Field(default=False)
reports: list["Report"] = Relationship(back_populates="user")
class Report(SQLModel, table=True):
path_id: int | None = Field(default=None, primary_key=True, foreign_key="path.id")
user_id: int | None = Field(default=None, primary_key=True, foreign_key="user.id")
timestamp: datetime | None = Field(default=datetime.now())
path: Path = Relationship(back_populates="reports")
user: User = Relationship(back_populates="reports")
################################################ ################################################
# API Models # API Models
################################################ ################################################

View File

@@ -40,7 +40,7 @@ app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
class ServerSettings(BaseSettings): class ServerSettings(BaseSettings):
db_url: str = "postgresql+psycopg2://slop-farmer@192.168.1.163/slop-farmer" db_url: str = "sqlite+pysqlite:///test_db.sqlite"
token_secret: str = "5bcc778a96b090c3ac1d587bb694a060eaf7bdb5832365f91d5078faf1fff210" token_secret: str = "5bcc778a96b090c3ac1d587bb694a060eaf7bdb5832365f91d5078faf1fff210"
# altcha_secret: str # altcha_secret: str
@@ -95,15 +95,21 @@ def generate_auth_token(username):
encoded_jwt = jwt.encode(bearer_token, TOKEN_SECRET, ALGO) encoded_jwt = jwt.encode(bearer_token, TOKEN_SECRET, ALGO)
return encoded_jwt return encoded_jwt
def get_token_user(decoded_token):
user = get_user(decoded_token["sub"], DB_ENGINE)
return user
def verify_auth_token(token: str): def verify_auth_token(token: str):
try: try:
token = jwt.decode(token, TOKEN_SECRET, ALGO, audience="slopserver") token = jwt.decode(token, TOKEN_SECRET, ALGO, audience="slopserver")
return token
except: except:
raise HTTPException(status_code=401, detail="invalid access token") raise HTTPException(status_code=401, detail="invalid access token")
@app.post("/report") @app.post("/report")
async def report_slop(report: SlopReport, bearer: Annotated[str, AfterValidator(verify_auth_token), Header()]): async def report_slop(report: SlopReport, bearer: Annotated[str, AfterValidator(verify_auth_token), Header()]):
insert_slop(report.slop_urls, DB_ENGINE) user = get_token_user(bearer)
insert_slop(report.slop_urls, DB_ENGINE, user)
@app.post("/check") @app.post("/check")
async def check_slop(check: Annotated[SlopReport, Body()], bearer: Annotated[str, AfterValidator(verify_auth_token), Header()]): async def check_slop(check: Annotated[SlopReport, Body()], bearer: Annotated[str, AfterValidator(verify_auth_token), Header()]):