Initial commit
This commit is contained in:
commit
85b06ae513
10 changed files with 312 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
__pycache__
|
||||||
|
.*.sw[po]
|
||||||
|
/venv/
|
||||||
|
*.sqlite
|
||||||
|
/files/
|
0
caricari/__init__.py
Normal file
0
caricari/__init__.py
Normal file
38
caricari/database.py
Normal file
38
caricari/database.py
Normal 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
21
caricari/httpcommon.py
Normal 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
122
caricari/private.py
Normal 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
59
caricari/public.py
Normal 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)
|
13
caricari/templates/form.html
Normal file
13
caricari/templates/form.html
Normal 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>
|
13
caricari/templates/ok.html
Normal file
13
caricari/templates/ok.html
Normal 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
7
config.toml
Normal 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
34
requirements.txt
Normal 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
|
Loading…
Reference in a new issue