Initial commit

This commit is contained in:
boyska 2024-11-29 10:51:31 +01:00
commit 85b06ae513
10 changed files with 312 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
__pycache__
.*.sw[po]
/venv/
*.sqlite
/files/

0
caricari/__init__.py Normal file
View file

38
caricari/database.py Normal file
View file

@ -0,0 +1,38 @@
from sqlalchemy import MetaData, Table, Column, Integer, String, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import DeclarativeBase, relationship, Mapped, mapped_column
# declarative base class
class Base(DeclarativeBase):
pass
metadata = Base.metadata
class Original(Base):
__tablename__ = "original"
id: Mapped[int] = mapped_column(primary_key=True)
uploader: Mapped[str]
upload_time: Mapped[int] # utc unix epoch
mime: Mapped[str]
filepath: Mapped[str]
name: Mapped[str]
size: Mapped[int]
sha256: Mapped[str] = Column(String, unique=True)
archived: Mapped[list["Archived"]] = relationship(back_populates="original")
class Archived(Base):
__tablename__ = "archived"
id = Column(Integer, primary_key=True)
original_id: Mapped[int] = mapped_column(ForeignKey("original.id"), nullable=False)
original: Mapped[Original] = relationship(back_populates="archived")
link: Mapped[str] # https://archive.org/...
format: Mapped[str] # "mp3"
archive: Mapped[str] # "archive.org"
archive_time: Mapped[int]
size: Mapped[int] = Column(Integer)
sha256: Mapped[str] = Column(String, unique=True)

21
caricari/httpcommon.py Normal file
View file

@ -0,0 +1,21 @@
import os
from pathlib import Path
import toml
from pydantic import BaseModel
class ProxyInfo(BaseModel):
# this class encapsulate all the info we get from the proxy via headers being set
x_forwarded_user: str | None = "anonymous"
def get_config():
conf = toml.load(open(os.getenv("CARICARI_CONFIG", "config.toml")))
conf.setdefault("private", {})
conf.setdefault("public", {})
conf.setdefault("general", {})
conf["general"]["files"] = Path(conf["general"]["files"])
return conf

122
caricari/private.py Normal file
View file

@ -0,0 +1,122 @@
import os
from pathlib import Path
from typing import Annotated
import datetime
import aiofiles
import tempfile
import hashlib
import toml
import magic
import sqlalchemy.exc
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from fastapi import FastAPI, UploadFile, File, Header, Form, HTTPException
from fastapi.requests import Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from .httpcommon import ProxyInfo, get_config
from . import database
CONFIG = get_config()
app = FastAPI()
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
engine = create_engine(CONFIG["general"]["db"])
database.metadata.create_all(engine)
session_pool = sessionmaker(bind=engine)
@app.get("/")
def home():
return RedirectResponse(url=app.url_path_for("upload_form"))
@app.get("/upload")
def upload_form(request: Request):
return templates.TemplateResponse(
request=request,
name="form.html",
)
class UploadModel(BaseModel):
file: UploadFile
@app.post("/upload")
async def upload(
data: Annotated[UploadModel, Form()],
proxy: Annotated[ProxyInfo, Header()],
request: Request,
):
now = datetime.datetime.now()
directory = f"{now.year}/{now.month}"
if not Path(data.file.filename).suffix:
raise HTTPException(status_code=400, detail="Invalid filename extension")
# XXX: normalize filename
# XXX: avoid duplicates
temp = tempfile.NamedTemporaryFile(
prefix=Path(data.file.filename).stem,
suffix=Path(data.file.filename).suffix,
dir=Path(CONFIG["general"]["files"]) / directory,
delete=False,
)
temp.close()
print(temp.name)
sha256 = hashlib.sha256()
try:
async with aiofiles.open(temp.name, "wb") as out_file:
while content := await data.file.read(64 * 1024):
await out_file.write(content)
sha256.update(content)
orig = database.Original(
filepath=str(Path(directory) / Path(temp.name).name),
name=data.file.filename,
uploader=proxy.x_forwarded_user,
upload_time=int(now.strftime("%s")),
mime=magic.from_file(temp.name, mime=True),
size=Path(temp.name).stat().st_size,
sha256=sha256.hexdigest(),
)
with session_pool() as conn:
conn.add(orig)
try:
conn.commit()
except sqlalchemy.exc.IntegrityError as exc:
conn.rollback()
conflicting = (
conn.query(database.Original)
.filter(database.Original.sha256 == sha256.hexdigest())
.one_or_none()
)
if conflicting:
detail = f"File is already in the archive: {conflicting.filepath}"
else:
detail = "File is already in the archive"
raise HTTPException(status_code=400, detail=detail)
conn.refresh(orig)
data = {"id": orig.id, "path": orig.filepath,
"url": CONFIG['public']['baseurl'] + "/dl/" + orig.filepath
}
if "application/json" in request.headers.get("accept", ""):
return data
else:
return templates.TemplateResponse(
name="ok.html", request=request, context=data)
except Exception as exc:
Path(temp.name).unlink()
raise
@app.post("/list")
def list():
return "lista file"

59
caricari/public.py Normal file
View file

@ -0,0 +1,59 @@
import os
from pathlib import Path
from typing import Annotated
import datetime
import aiofiles
import tempfile
import hashlib
import magic
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, joinedload
from fastapi import FastAPI, HTTPException
from fastapi.requests import Request
from fastapi.responses import RedirectResponse, FileResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from .httpcommon import ProxyInfo, get_config
from . import database
CONFIG = get_config()
app = FastAPI()
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
engine = create_engine(CONFIG["general"]["db"])
database.metadata.create_all(engine)
session_pool = sessionmaker(bind=engine)
@app.get("/")
def home():
return "public archive"
@app.get("/dl/{path:path}")
async def get_file(path: str):
with session_pool() as conn:
original = (
conn.query(database.Original)
.options(joinedload(database.Original.archived))
.filter(database.Original.filepath == path)
.one_or_none()
)
if original is None:
raise HTTPException(status_code=404)
if not original.archived:
final_path = Path(CONFIG["general"]["files"]) / original.filepath
if final_path.exists():
return FileResponse(
# XXX: avoid it being an attachment
final_path, media_type=original.mime, filename=original.name
)
else:
return 404
# XXX: handle ?accept=
return RedirectResponse(original.archived[0].link)

View file

@ -0,0 +1,13 @@
<html>
<head>
<title>Upload file</title>
</head>
<body>
<h1>Upload file</h1>
<form method="POST" enctype="multipart/form-data">
<input type="file" name="file" />
<input type=submit />
</form>
</body>
</html>

View file

@ -0,0 +1,13 @@
<html>
<head>
<title>File has been uploaded</title>
</head>
<body>
<h1>Success!</h1>
<p>
File has been loaded at <tt>{{url}}</tt>
</p>
</body>
</html>

7
config.toml Normal file
View file

@ -0,0 +1,7 @@
[general]
logging = 'DEBUG'
db = 'sqlite:///db.sqlite'
files = 'files/'
[public]
baseurl = 'http://127.0.0.1:8001'

34
requirements.txt Normal file
View file

@ -0,0 +1,34 @@
annotated-types==0.7.0
anyio==4.6.2.post1
certifi==2024.8.30
click==8.1.7
dnspython==2.7.0
email_validator==2.2.0
fastapi==0.115.5
fastapi-cli==0.0.5
h11==0.14.0
httpcore==1.0.7
httptools==0.6.4
httpx==0.28.0
idna==3.10
Jinja2==3.1.4
markdown-it-py==3.0.0
MarkupSafe==3.0.2
mdurl==0.1.2
pydantic==2.10.2
pydantic_core==2.27.1
Pygments==2.18.0
python-dotenv==1.0.1
python-multipart==0.0.18
PyYAML==6.0.2
rich==13.9.4
shellingham==1.5.4
sniffio==1.3.1
starlette==0.41.3
toml==0.10.2
typer==0.14.0
typing_extensions==4.12.2
uvicorn==0.32.1
uvloop==0.21.0
watchfiles==1.0.0
websockets==14.1