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…
	
	Add table
		Add a link
		
	
		Reference in a new issue