diff --git a/entry.sh b/entry.sh old mode 100644 new mode 100755 diff --git a/foxfiles/__init__.py b/foxfiles/__init__.py index f50c084..5af2281 100644 --- a/foxfiles/__init__.py +++ b/foxfiles/__init__.py @@ -1,4 +1,3 @@ # SPDX-License-Identifier: Apache-2.0 from .app import app -from .db import User, File, engine diff --git a/foxfiles/app.py b/foxfiles/app.py index 4857844..b5ca9ff 100644 --- a/foxfiles/app.py +++ b/foxfiles/app.py @@ -3,9 +3,12 @@ from flask import Flask from foxfiles.settings import SECRET_KEY -from foxfiles.blueprints import files_bp +from foxfiles import blueprints app = Flask(__name__) app.secret_key = SECRET_KEY -app.register_blueprint(files_bp) +app.register_blueprint(blueprints.files) +app.register_blueprint(blueprints.user_api) +app.register_blueprint(blueprints.upload_api) +app.register_blueprint(blueprints.files_api) diff --git a/foxfiles/blueprints/__init__.py b/foxfiles/blueprints/__init__.py index a517b7e..523299e 100644 --- a/foxfiles/blueprints/__init__.py +++ b/foxfiles/blueprints/__init__.py @@ -1,3 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 -from .files import bp as files_bp +from .files import bp as files +from .api.user import bp as user_api +from .api.upload import bp as upload_api +from .api.files import bp as files_api diff --git a/foxfiles/blueprints/api/files.py b/foxfiles/blueprints/api/files.py new file mode 100644 index 0000000..ae5256d --- /dev/null +++ b/foxfiles/blueprints/api/files.py @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: Apache-2.0 + +from flask import Blueprint, g +from flask_pydantic import validate +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.orm import Session + +from foxfiles.db import File, engine +from foxfiles.user import maybe_token, require_user +from foxfiles.settings import BASE_URL + +bp = Blueprint("files_api", __name__) + + +class ListFilesQuery(BaseModel): + before: int | None = None + after: int | None = None + search: str | None = None + + +class ListFilesResponse(BaseModel): + id: int + url_id: str + filename: str + hash: str + content_type: str + url: str + + +@bp.get("/api/files") +@maybe_token +@require_user +@validate() +def list_files(query: ListFilesQuery): + stmt = select(File).where(File.user_id == g.user.id).limit(50).order_by(File.id.desc()) + if query.before: + stmt = stmt.where(File.id < query.before) + if query.after: + stmt = stmt.where(File.id > query.after) + if query.search: + stmt = stmt.where(File.filename.like(f"%{query.search}%")) + + with Session(engine) as session: + files = session.scalars(stmt) + return [ + ListFilesResponse( + id=file.id, + url_id=file.url_id, + filename=file.filename, + hash=file.hash, + content_type=file.content_type, + url=f"{BASE_URL}/{file.path}", + ).model_dump() + for file in files + ] diff --git a/foxfiles/blueprints/api/upload.py b/foxfiles/blueprints/api/upload.py new file mode 100644 index 0000000..b9bd206 --- /dev/null +++ b/foxfiles/blueprints/api/upload.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: Apache-2.0 + +from flask import Blueprint, g, request +from flask_pydantic import validate +from pydantic import BaseModel +from sqlalchemy.orm import Session +from werkzeug.utils import secure_filename + +from foxfiles.errors import Errors +from foxfiles.db import File, engine +from foxfiles.files import upload_file +from foxfiles.user import maybe_token, require_user +from foxfiles.settings import BASE_URL + +bp = Blueprint("upload_api", __name__) + + +class UploadResponse(BaseModel): + id: int + url_id: str + filename: str + hash: str + content_type: str + url: str + + +@bp.post("/api/upload") +@maybe_token +@require_user +@validate() +def upload(): + file = request.files.get("file") + if not file: + return Errors.MISSING_FILE + + hash, content_type = upload_file(file) + + with Session(engine) as session: + db_file = File( + filename=secure_filename(file.filename), + hash=hash, + content_type=content_type, + user_id=g.user.id, + ) + session.add(db_file) + session.commit() + + return UploadResponse( + id=db_file.id, + url_id=db_file.url_id, + filename=db_file.filename, + hash=db_file.hash, + content_type=db_file.content_type, + url=f"{BASE_URL}/{db_file.path}", + ) diff --git a/foxfiles/blueprints/api/user.py b/foxfiles/blueprints/api/user.py new file mode 100644 index 0000000..a70e6b2 --- /dev/null +++ b/foxfiles/blueprints/api/user.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: Apache-2.0 + +from flask import Blueprint, g +from flask_pydantic import validate +from pydantic import BaseModel +from sqlalchemy import select, func +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from foxfiles.errors import Errors +from foxfiles.db import User, engine +from foxfiles.user import maybe_token + +bp = Blueprint("user_api", __name__) + + +class CreateUserRequest(BaseModel): + username: str + password: str + is_admin: bool = False + + +class UserResponse(BaseModel): + id: int + username: str + is_admin: bool + token: str + + +@bp.post("/api/users") +@maybe_token +@validate() +def create_user(body: CreateUserRequest): + with Session(engine) as session: + user_count = session.scalar(select(func.count(User.id))) + if user_count == 0: + # if there's no users, create the user (as admin) and return + user = User(body.username, body.password, True) + session.add(user) + session.commit() + + return UserResponse( + id=user.id, + username=user.username, + is_admin=user.is_admin, + token=user.get_token(), + ) + # else, check if the user is an admin + if "user" not in g: + return Errors.UNAUTHORIZED + if not g.user.is_admin: + return Errors.FORBIDDEN + + try: + user = User(body.username, body.password, body.is_admin) + session.add(user) + session.commit() + except IntegrityError as e: + return {"error": e._message()}, 400 + + return UserResponse( + id=user.id, + username=user.username, + is_admin=user.is_admin, + token=user.get_token(), + ) + + +class LoginRequest(BaseModel): + username: str + password: str + + +@bp.post("/api/users/login") +@validate() +def login(body: LoginRequest): + with Session(engine) as session: + user = session.scalar(select(User).where(User.username == body.username)) + if user is None: + return Errors.INVALID_CREDENTIALS + if user.verify_password(body.password): + return UserResponse( + id=user.id, + username=user.username, + is_admin=user.is_admin, + token=user.get_token(), + ) + else: + return Errors.INVALID_CREDENTIALS diff --git a/foxfiles/blueprints/files.py b/foxfiles/blueprints/files.py index 1ae1105..ae955ce 100644 --- a/foxfiles/blueprints/files.py +++ b/foxfiles/blueprints/files.py @@ -10,13 +10,13 @@ from foxfiles.db import engine, File bp = Blueprint("files", __name__) -@bp.route("/.") +@bp.get("/.") def get_file_id(file_id: str, extension: str): """Gets a file by its short ID.""" return _get_file(select(File).where(File.url_id == file_id)) -@bp.route("//") +@bp.get("//") def get_file_by_hash_name(hash: str, filename: str): """Gets a file by its hash and filename.""" return _get_file( diff --git a/foxfiles/db.py b/foxfiles/db.py index b9e6533..20b6773 100644 --- a/foxfiles/db.py +++ b/foxfiles/db.py @@ -1,8 +1,9 @@ # SPDX-License-Identifier: Apache-2.0 -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from base64 import urlsafe_b64encode from os import urandom +import mimetypes from argon2 import PasswordHasher from itsdangerous.url_safe import URLSafeTimedSerializer @@ -30,8 +31,9 @@ class User(Base): DateTime(), default=func.now() ) created_at: Mapped[datetime] = mapped_column(DateTime(), default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(), default=func.now(), onupdate=func.now()) - + updated_at: Mapped[datetime] = mapped_column( + DateTime(), default=func.now(), onupdate=func.now() + ) files: Mapped[list["File"]] = relationship( back_populates="user", cascade="all, delete-orphan" @@ -39,7 +41,12 @@ class User(Base): def __init__(self, username: str, password: str, is_admin: bool = False): self.username = username - self.set_password(password) + self.password = ph.hash(password) + # the value returned by the timestamp signer is only precise to the nearest second, while this is precise to the millisecond. + # so tokens generated immediately after user creation/changing passwords will not be valid. + # this is fine for existing users--it takes a few seconds to log back in and generate the first new token, anyway + # but it breaks signing up + self.password_changed_at = datetime.utcnow() - timedelta(seconds=30) self.is_admin = is_admin def __repr__(self): @@ -68,7 +75,6 @@ class User(Base): try: value, timestamp = signer.loads(token, return_timestamp=True) first_valid = self.password_changed_at.replace(tzinfo=timezone.utc) - print(value, timestamp, first_valid) return timestamp > first_valid and value == str(self.id) except: return False @@ -82,19 +88,28 @@ class File(Base): __tablename__ = "files" id: Mapped[int] = mapped_column(primary_key=True) - url_id: Mapped[str] = mapped_column( - Text(), unique=True, default=_random_url_id - ) + url_id: Mapped[str] = mapped_column(Text(), unique=True, default=_random_url_id) filename: Mapped[str] = mapped_column(Text()) hash: Mapped[str] = mapped_column(Text()) content_type: Mapped[str] = mapped_column(Text()) created_at: Mapped[datetime] = mapped_column(DateTime(), default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(), default=func.now(), onupdate=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(), default=func.now(), onupdate=func.now() + ) user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) user: Mapped[User] = relationship(back_populates="files") + @property + def path(self): + ext = mimetypes.guess_extension(self.content_type, strict=False) + if not ext: + ext = "" + + return f"{self.url_id}{ext}" + + engine = create_engine(f"sqlite:///{DATABASE}", echo=ECHO_QUERIES) diff --git a/foxfiles/errors.py b/foxfiles/errors.py new file mode 100644 index 0000000..e63120d --- /dev/null +++ b/foxfiles/errors.py @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: Apache-2.0 + + +class Errors: + """Error responses""" + + UNAUTHORIZED = ({"error": "Unauthorized"}, 401) + FORBIDDEN = ({"error": "Forbidden"}, 403) + NOT_FOUND = ({"error": "Not found"}, 404) + + MISSING_TOKEN = ({"error": "Missing token"}, 401) + INVALID_TOKEN = ({"error": "Invalid token"}, 401) + INVALID_CREDENTIALS = ({"error": "Invalid credentials"}, 400) + MISSING_FILE = ({"error": "Missing file"}, 400) diff --git a/foxfiles/user.py b/foxfiles/user.py new file mode 100644 index 0000000..8c63aca --- /dev/null +++ b/foxfiles/user.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: Apache-2.0 + +from functools import wraps + +from itsdangerous.url_safe import URLSafeTimedSerializer +from flask import g, request +from sqlalchemy import select +from sqlalchemy.orm import Session + +from foxfiles.errors import Errors +from foxfiles.settings import SECRET_KEY +from foxfiles.db import User, engine + + +def require_user(f): + @wraps(f) + def inner(*args, **kwargs): + if "user" not in g: + return Errors.MISSING_TOKEN + + return f(*args, **kwargs) + + return inner + + +def maybe_token(f): + @wraps(f) + def inner(*args, **kwargs): + token = request.headers.get("Authorization") + if not token: + return f(*args, **kwargs) + + _, id = URLSafeTimedSerializer(SECRET_KEY).loads_unsafe(token) + try: + id_int = int(id) + except: + return Errors.INVALID_TOKEN + + with Session(engine) as session: + u: User = session.scalar(select(User).where(User.id == id_int)) + if u is None: + return Errors.INVALID_TOKEN + + if not u.verify_token(token): + return Errors.INVALID_TOKEN + g.user = u + + return f(*args, **kwargs) + + return inner