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
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
from .app import app
|
from .app import app
|
||||||
from .db import User, File, engine
|
|
||||||
|
|
|
@ -3,9 +3,12 @@
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
from foxfiles.settings import SECRET_KEY
|
from foxfiles.settings import SECRET_KEY
|
||||||
from foxfiles.blueprints import files_bp
|
from foxfiles import blueprints
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = SECRET_KEY
|
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
|
# 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 = Blueprint("files", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<file_id>.<extension>")
|
@bp.get("/<file_id>.<extension>")
|
||||||
def get_file_id(file_id: str, extension: str):
|
def get_file_id(file_id: str, extension: str):
|
||||||
"""Gets a file by its short ID."""
|
"""Gets a file by its short ID."""
|
||||||
return _get_file(select(File).where(File.url_id == file_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):
|
def get_file_by_hash_name(hash: str, filename: str):
|
||||||
"""Gets a file by its hash and filename."""
|
"""Gets a file by its hash and filename."""
|
||||||
return _get_file(
|
return _get_file(
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
# SPDX-License-Identifier: Apache-2.0
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone, timedelta
|
||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
from os import urandom
|
from os import urandom
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from itsdangerous.url_safe import URLSafeTimedSerializer
|
from itsdangerous.url_safe import URLSafeTimedSerializer
|
||||||
|
@ -30,8 +31,9 @@ class User(Base):
|
||||||
DateTime(), default=func.now()
|
DateTime(), default=func.now()
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(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(
|
files: Mapped[list["File"]] = relationship(
|
||||||
back_populates="user", cascade="all, delete-orphan"
|
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):
|
def __init__(self, username: str, password: str, is_admin: bool = False):
|
||||||
self.username = username
|
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
|
self.is_admin = is_admin
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
@ -68,7 +75,6 @@ class User(Base):
|
||||||
try:
|
try:
|
||||||
value, timestamp = signer.loads(token, return_timestamp=True)
|
value, timestamp = signer.loads(token, return_timestamp=True)
|
||||||
first_valid = self.password_changed_at.replace(tzinfo=timezone.utc)
|
first_valid = self.password_changed_at.replace(tzinfo=timezone.utc)
|
||||||
print(value, timestamp, first_valid)
|
|
||||||
return timestamp > first_valid and value == str(self.id)
|
return timestamp > first_valid and value == str(self.id)
|
||||||
except:
|
except:
|
||||||
return False
|
return False
|
||||||
|
@ -82,19 +88,28 @@ class File(Base):
|
||||||
__tablename__ = "files"
|
__tablename__ = "files"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
url_id: Mapped[str] = mapped_column(
|
url_id: Mapped[str] = mapped_column(Text(), unique=True, default=_random_url_id)
|
||||||
Text(), unique=True, default=_random_url_id
|
|
||||||
)
|
|
||||||
filename: Mapped[str] = mapped_column(Text())
|
filename: Mapped[str] = mapped_column(Text())
|
||||||
hash: Mapped[str] = mapped_column(Text())
|
hash: Mapped[str] = mapped_column(Text())
|
||||||
content_type: Mapped[str] = mapped_column(Text())
|
content_type: Mapped[str] = mapped_column(Text())
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(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()
|
||||||
|
)
|
||||||
|
|
||||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||||
user: Mapped[User] = relationship(back_populates="files")
|
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)
|
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