add user create, login, upload, list files endpoints
This commit is contained in:
parent
ed0bd8f986
commit
7467562c8f
11 changed files with 299 additions and 15 deletions
0
entry.sh
Normal file → Executable file
0
entry.sh
Normal file → Executable file
|
@ -1,4 +1,3 @@
|
|||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .app import app
|
||||
from .db import User, File, engine
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
56
foxfiles/blueprints/api/files.py
Normal file
56
foxfiles/blueprints/api/files.py
Normal 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
|
||||
]
|
55
foxfiles/blueprints/api/upload.py
Normal file
55
foxfiles/blueprints/api/upload.py
Normal 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}",
|
||||
)
|
89
foxfiles/blueprints/api/user.py
Normal file
89
foxfiles/blueprints/api/user.py
Normal 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
|
|
@ -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(
|
||||
|
|
|
@ -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
14
foxfiles/errors.py
Normal 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
50
foxfiles/user.py
Normal 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
|
Loading…
Reference in a new issue