add user create, login, upload, list files endpoints

This commit is contained in:
sam 2024-02-12 17:08:19 +01:00
parent ed0bd8f986
commit 7467562c8f
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
11 changed files with 299 additions and 15 deletions

0
entry.sh Normal file → Executable file
View file

View file

@ -1,4 +1,3 @@
# SPDX-License-Identifier: Apache-2.0
from .app import app
from .db import User, File, engine

View file

@ -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)

View file

@ -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

View file

@ -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
]

View file

@ -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}",
)

View file

@ -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

View file

@ -10,13 +10,13 @@ from foxfiles.db import engine, File
bp = Blueprint("files", __name__)
@bp.route("/<file_id>.<extension>")
@bp.get("/<file_id>.<extension>")
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("/<hash>/<filename>")
@bp.get("/<hash>/<filename>")
def get_file_by_hash_name(hash: str, filename: str):
"""Gets a file by its hash and filename."""
return _get_file(

View file

@ -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)

14
foxfiles/errors.py Normal file
View file

@ -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)

50
foxfiles/user.py Normal file
View file

@ -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