diff --git a/freedium-library/pdm.lock b/freedium-library/pdm.lock index fb9a7c7..597efe2 100644 --- a/freedium-library/pdm.lock +++ b/freedium-library/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "api", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:0503cfae0ec00418ac476d9055e2f0930705f732b82759dbee8e88ace7c7eeab" +content_hash = "sha256:c8d5376c4072286f4b254f030eb7bf2e315723b69831378eea9d454568df6bad" [[metadata.targets]] requires_python = "==3.12.*" @@ -148,7 +148,7 @@ name = "dnspython" version = "2.7.0" requires_python = ">=3.9" summary = "DNS toolkit" -groups = ["api"] +groups = ["default", "api"] files = [ {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, @@ -503,6 +503,20 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "motor" +version = "3.6.0" +requires_python = ">=3.8" +summary = "Non-blocking MongoDB driver for Tornado or asyncio" +groups = ["default"] +dependencies = [ + "pymongo<4.10,>=4.9", +] +files = [ + {file = "motor-3.6.0-py3-none-any.whl", hash = "sha256:9f07ed96f1754963d4386944e1b52d403a5350c687edc60da487d66f98dbf894"}, + {file = "motor-3.6.0.tar.gz", hash = "sha256:0ef7f520213e852bf0eac306adf631aabe849227d8aec900a2612512fb9c5b8d"}, +] + [[package]] name = "msgspec" version = "0.18.6" @@ -743,6 +757,28 @@ files = [ {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, ] +[[package]] +name = "pymongo" +version = "4.9.2" +requires_python = ">=3.8" +summary = "Python driver for MongoDB " +groups = ["default"] +dependencies = [ + "dnspython<3.0.0,>=1.16.0", +] +files = [ + {file = "pymongo-4.9.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8083bbe8cb10bb33dca4d93f8223dd8d848215250bb73867374650bac5fe69e1"}, + {file = "pymongo-4.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a1b8c636bf557c7166e3799bbf1120806ca39e3f06615b141c88d9c9ceae4d8c"}, + {file = "pymongo-4.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8aac5dce28454f47576063fbad31ea9789bba67cab86c95788f97aafd810e65b"}, + {file = "pymongo-4.9.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1d5e7123af1fddf15b2b53e58f20bf5242884e671bcc3860f5e954fe13aeddd"}, + {file = "pymongo-4.9.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe97c847b56d61e533a7af0334193d6b28375b9189effce93129c7e4733794a9"}, + {file = "pymongo-4.9.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96ad54433a996e2d1985a9cd8fc82538ca8747c95caae2daf453600cc8c317f9"}, + {file = "pymongo-4.9.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:98b9cade40f5b13e04492a42ae215c3721099be1014ddfe0fbd23f27e4f62c0c"}, + {file = "pymongo-4.9.2-cp312-cp312-win32.whl", hash = "sha256:dde6068ae7c62ea8ee2c5701f78c6a75618cada7e11f03893687df87709558de"}, + {file = "pymongo-4.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:e1ab6cd7cd2d38ffc7ccdc79fdc166c7a91a63f844a96e3e6b2079c054391c68"}, + {file = "pymongo-4.9.2.tar.gz", hash = "sha256:3e63535946f5df7848307b9031aa921f82bb0cbe45f9b0c3296f2173f9283eb0"}, +] + [[package]] name = "pytest" version = "8.3.3" diff --git a/freedium-library/pyproject.toml b/freedium-library/pyproject.toml index 9f65001..01863a9 100644 --- a/freedium-library/pyproject.toml +++ b/freedium-library/pyproject.toml @@ -12,7 +12,8 @@ dependencies = [ "pendulum>=3.0.0", "ujson>=5.10.0", "httpx[socks]>=0.27.2", - "asyncclick>=8.1.7.2", + "pymongo>=4.9.2", + "motor>=3.6.0", ] requires-python = "==3.12.*" readme = "README.md" diff --git a/freedium-library/src/freedium_library/__init__.py b/freedium-library/src/freedium_library/__init__.py index b77b1f2..70a7266 100644 --- a/freedium-library/src/freedium_library/__init__.py +++ b/freedium-library/src/freedium_library/__init__.py @@ -1 +1,2 @@ +__NAME__ = "Freedium Library" __VERSION__ = "0.0.1" diff --git a/freedium-library/src/freedium_library/api/__init__.py b/freedium-library/src/freedium_library/api/__init__.py index 3c06bd1..812c6bc 100644 --- a/freedium-library/src/freedium_library/api/__init__.py +++ b/freedium-library/src/freedium_library/api/__init__.py @@ -1,5 +1,4 @@ from freedium_library.__init__ import __VERSION__ __NAME__ = "Freedium Library API" - __all__ = ["__NAME__", "__VERSION__"] diff --git a/freedium-library/src/freedium_library/api/app.py b/freedium-library/src/freedium_library/api/app.py index 8c207da..51bb133 100644 --- a/freedium-library/src/freedium_library/api/app.py +++ b/freedium-library/src/freedium_library/api/app.py @@ -1,4 +1,5 @@ from fastapi import FastAPI +from loguru import logger from freedium_library.api.container import APIContainer from freedium_library.api.error import register_error_handler @@ -7,18 +8,21 @@ from freedium_library.api.lifespan import lifespan from freedium_library.api.middlewares import register_middlewares from freedium_library.api.settings import ApplicationSettings -container = APIContainer() - def create_application() -> FastAPI: - settings = ApplicationSettings() + container = APIContainer() + container.wire(modules=["freedium_library.api.settings"]) - if container.config.DISABLE_DOCS: + settings = ApplicationSettings(container=container) + config = container.config() + + if config.DISABLED_DOCS: + logger.warning(f"Documentation is disabled: {config.DISABLED_DOCS}") settings.disable_docs() app = FastAPI(**settings.to_dict(), lifespan=lifespan) # type: ignore - register_router(app, container.config.PREFIX_PATH) + register_router(app, settings.prefix_path) register_error_handler(app) register_middlewares(app) diff --git a/freedium-library/src/freedium_library/api/config.py b/freedium-library/src/freedium_library/api/config.py index 9fcb858..b1810d8 100644 --- a/freedium-library/src/freedium_library/api/config.py +++ b/freedium-library/src/freedium_library/api/config.py @@ -1,8 +1,10 @@ +from pydantic import Field + from freedium_library.utils.meta.pydantic import BaseConfig, BaseSettingsConfigDict class APIConfig(BaseConfig): model_config = BaseSettingsConfigDict(env_prefix="API_") - DISABLE_DOCS: bool = True - PREFIX_PATH: str = "/api" + DISABLED_DOCS: bool = Field(default=False) + PREFIX_PATH: str = Field(default="/api") diff --git a/freedium-library/src/freedium_library/api/container.py b/freedium-library/src/freedium_library/api/container.py index 563df5f..7cb7c8f 100644 --- a/freedium-library/src/freedium_library/api/container.py +++ b/freedium-library/src/freedium_library/api/container.py @@ -4,4 +4,4 @@ from freedium_library.api.config import APIConfig class APIContainer(containers.DeclarativeContainer): - config: APIConfig = providers.Singleton(APIConfig) # type: ignore + config = providers.Singleton(APIConfig) diff --git a/freedium-library/src/freedium_library/api/handlers/render.py b/freedium-library/src/freedium_library/api/handlers/render.py index c3833b2..05eb8fb 100644 --- a/freedium-library/src/freedium_library/api/handlers/render.py +++ b/freedium-library/src/freedium_library/api/handlers/render.py @@ -1,12 +1,166 @@ +from functools import partial + from fastapi import APIRouter +from fastapi.responses import JSONResponse -handler_router = APIRouter() +TEXT = """# V7 Is Here! 🚀 + +> "The best file upload solution just got even better!" - _Tech Weekly_ + +This release has been an absurd amount of work. So proud of the team and what we've built. Huge thanks to [Julius](https://github.com/julius) and [Mark](https://github.com/mark) for making this happen. + +--- + +It is so, so hard to not go straight into the nerdy details, but the whole point of UploadThing is that you don't need to know ANY of those details. With that in mind, here's what's relevant for most y'all: + +- UploadThing is now _way_ faster +- Uploads can be **paused** and _resumed_ seamlessly +- ~~Old limitations removed~~ +- More details... + +## Performance Comparison + +| Feature | V6 | V7 | +| ------------------ | ------ | --------- | +| Upload Speed | 10MB/s | 50MB/s | +| Concurrent Uploads | 100 | Unlimited | +| Max File Size | 2GB | 10GB | + +## Revolutionary Features + +We've completely overhauled our backend infrastructure to bring you unparalleled performance. Our new distributed processing system can handle millions of concurrent uploads without breaking a sweat. + +### Code Example + +```typescript +import { createUploadthing } from 'uploadthing/next'; + +const f = createUploadthing(); + +export const ourFileRouter = { + imageUploader: f({ image: { maxFileSize: '4MB' } }) + .middleware(async () => { + return { userId: 1234 }; + }) + .onUploadComplete(async ({ metadata, file }) => { + console.log('Upload complete for userId:', metadata.userId); + }) +}; +``` + +![Upload Dashboard](https://example.com/dashboard.png) + +### AI-Powered Optimization + +UploadThing now leverages cutting-edge machine learning algorithms to optimize your uploads in real-time. + +
+Technical Details + +- Uses TensorFlow.js for client-side optimizations +- Implements WebAssembly for performance +- Leverages Web Workers for background processing + +
+ +## More Formatting Examples + +### Advanced Markdown + +#### Lists and Sublists + +- Main Feature + - Sub-feature 1 + - Sub-feature 2 + - Detailed point + - Another detail +- Another Feature + 1. First step + 2. Second step + 3. Third step + +#### Text Formatting + +_Italic text_ and _another italic_ +**Bold text** and **another bold** +**_Bold and italic_** **_together_** +~~Strikethrough text~~ +`inline code` + +### HTML Elements + +
+

Centered Content

+

This content is centered using `HTML`

+
+ +Ctrl + C to copy + +Highlighted text + +
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +
+ +
+Click to expand +This is hidden content that can be expanded! + + +
+ + + +### Custom Styling + +
+

Custom styled content box

+ +
+ +--- + +_For more information, visit our [documentation](https://docs.uploadthing.com)._ + +```js +console.log('Hello, world!'); +``` + +```js +console.log('Hello, world!'); +console.log('Hello, world!'); +console.log('Hello, world!'); +``` + +[![Why React Hooks are the Future of React Development](https://medium.com/@johndoe/why-react-hooks-are-the-future)](https://miro.medium.com/v2/resize:fit:320/1*abc123.jpg) +> **Why React Hooks are the Future of React Development** +> Learn how React Hooks are revolutionizing the way we write components and manage state in React applications. Discover the benefits and best practices. +""" -@handler_router.get("/render") -async def render_page(): - return "render" +async def render_page(service_name: str): + return JSONResponse(content={"text": TEXT, "service_name": service_name}) def register_render_router(router: APIRouter) -> None: - router.add_api_route(path="/render", endpoint=render_page, methods=["GET"]) + render_router = APIRouter(prefix="/services") + + for service_name in ["medium", "twitter"]: + render_router.add_api_route( + path=f"/{service_name}/render", + endpoint=partial(render_page, service_name), + summary=f"Render {service_name} page", + description=f"Render {service_name} page", + methods=["GET", "HEAD"], + ) + + router.include_router(render_router) diff --git a/freedium-library/src/freedium_library/api/main.py b/freedium-library/src/freedium_library/api/main.py index f265a71..fee03b9 100644 --- a/freedium-library/src/freedium_library/api/main.py +++ b/freedium-library/src/freedium_library/api/main.py @@ -1,5 +1,24 @@ +import os +from typing import Optional + import uvicorn +from loguru import logger -def start_server(host: str = "0.0.0.0", port: int = 7080) -> None: - uvicorn.run("freedium_library.api.app:app", host=host, port=port) +def start_server( + host: str = "0.0.0.0", + port: int = 7080, + reload: bool = False, + workers: Optional[int] = None, +) -> None: + if workers is None: + workers = (os.cpu_count() or 1) + 1 + logger.warning(f"No workers specified, using CPU count {workers}") + + uvicorn.run( + "freedium_library.api.app:app", + host=host, + port=port, + reload=reload, + workers=workers, + ) diff --git a/freedium-library/src/freedium_library/api/middlewares/__init__.py b/freedium-library/src/freedium_library/api/middlewares/__init__.py index f6a9c6c..3524576 100644 --- a/freedium-library/src/freedium_library/api/middlewares/__init__.py +++ b/freedium-library/src/freedium_library/api/middlewares/__init__.py @@ -1,5 +1,12 @@ from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware def register_middlewares(app: FastAPI) -> None: - pass + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) diff --git a/freedium-library/src/freedium_library/api/settings.py b/freedium-library/src/freedium_library/api/settings.py index 6031b9c..b1d20fe 100644 --- a/freedium-library/src/freedium_library/api/settings.py +++ b/freedium-library/src/freedium_library/api/settings.py @@ -1,18 +1,41 @@ -from dataclasses import dataclass +from typing import Any, Optional + +from dependency_injector.wiring import Provide, inject +from pydantic import BaseModel, Field from freedium_library import __NAME__, __VERSION__ +from freedium_library.api.config import APIConfig from freedium_library.api.container import APIContainer -container = APIContainer() +class ApplicationSettings(BaseModel): + title: str = Field(default=f"{__NAME__} API Service") + version: str = Field(default=__VERSION__) + prefix_path: str = Field(default="/api") + openapi_url: Optional[str] = None + docs_url: Optional[str] = None + redoc_url: Optional[str] = None -@dataclass -class ApplicationSettings: - title: str = __NAME__ - version: str = __VERSION__ - openapi_url: str | None = f"{container.config.PREFIX_PATH}/openapi.json" - docs_url: str | None = f"{container.config.PREFIX_PATH}/docs" - redoc_url: str | None = f"{container.config.PREFIX_PATH}/redoc" + model_config = {"arbitrary_types_allowed": True} + + @inject + def __init__( + self, + title: str = __NAME__, + version: str = __VERSION__, + api_config: APIConfig = Provide[APIContainer.config], + **data: Any, + ): + prefix_path = api_config.PREFIX_PATH + super().__init__( + title=title, + version=version, + prefix_path=prefix_path, + openapi_url=f"{prefix_path}/openapi.json", + docs_url=f"{prefix_path}/docs", + redoc_url=f"{prefix_path}/redoc", + **data, + ) def disable_docs(self) -> None: self.openapi_url = None @@ -20,10 +43,4 @@ class ApplicationSettings: self.redoc_url = None def to_dict(self) -> dict[str, str | None]: - return { - "title": self.title, - "version": self.version, - "openapi_url": self.openapi_url, - "docs_url": self.docs_url, - "redoc_url": self.redoc_url, - } + return self.model_dump() diff --git a/freedium-library/src/freedium_library/cli.py b/freedium-library/src/freedium_library/cli.py index 032f7f3..e1ccbfb 100644 --- a/freedium-library/src/freedium_library/cli.py +++ b/freedium-library/src/freedium_library/cli.py @@ -1,10 +1,16 @@ -import asyncclick as click +import click @click.command() @click.option("--host", default="0.0.0.0", help="Host address to bind to") @click.option("--port", default=7080, type=int, help="Port to listen on") -async def cli(host: str, port: int): +@click.option( + "--hot-reload", + is_flag=True, + help="Reload the server on code changes", + default=False, +) +def cli(host: str, port: int, hot_reload: bool): from freedium_library.api.main import start_server - start_server(host=host, port=port) + start_server(host=host, port=port, reload=hot_reload) diff --git a/freedium-library/src/freedium_library/services/medium/api.py b/freedium-library/src/freedium_library/services/medium/api.py index 6b563ce..b8f005f 100644 --- a/freedium-library/src/freedium_library/services/medium/api.py +++ b/freedium-library/src/freedium_library/services/medium/api.py @@ -41,8 +41,8 @@ class MediumApiService: "Connection": "Keep-Alive", } - if self.auth_cookies is not None: - headers["Cookie"] = self.auth_cookies + if self.config.cookies is not None: + headers["Cookie"] = self.config.cookies graphql_data = { "operationName": "FullPostQuery", diff --git a/src/freedium_library/utils/json_test.py b/freedium-library/src/freedium_library/utils/cache/__init__.py similarity index 100% rename from src/freedium_library/utils/json_test.py rename to freedium-library/src/freedium_library/utils/cache/__init__.py diff --git a/freedium-library/src/freedium_library/utils/cache/db.py b/freedium-library/src/freedium_library/utils/cache/db.py new file mode 100644 index 0000000..e69de29 diff --git a/freedium-library/src/freedium_library/utils/cache/db/__init__.py b/freedium-library/src/freedium_library/utils/cache/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freedium-library/src/freedium_library/utils/cache/db/base.py b/freedium-library/src/freedium_library/utils/cache/db/base.py new file mode 100644 index 0000000..503e568 --- /dev/null +++ b/freedium-library/src/freedium_library/utils/cache/db/base.py @@ -0,0 +1,103 @@ +from abc import ABC, abstractmethod +from typing import List, Union + +from ..models import CacheResponse + + +class AbstractCacheBackend(ABC): + @abstractmethod + def connect(self) -> None: + """Initialize database connection""" + raise NotImplementedError("Connect must be implemented by subclass") + + @abstractmethod + def ensure_connection(self) -> None: + """Ensure database connection is established""" + raise NotImplementedError("Ensure_connection must be implemented by subclass") + + @abstractmethod + async def aensure_connection(self) -> None: + """Ensure database connection is established asynchronously""" + raise NotImplementedError( + "Async ensure_connection must be implemented by subclass" + ) + + @abstractmethod + def init_db(self) -> None: + """Initialize database schema""" + raise NotImplementedError("Init_db must be implemented by subclass") + + @abstractmethod + async def ainit_db(self) -> None: + """Initialize database schema asynchronously""" + raise NotImplementedError("Async init_db must be implemented by subclass") + + @abstractmethod + def all(self) -> List[dict]: + """Retrieve all cached items""" + raise NotImplementedError("All must be implemented by subclass") + + @abstractmethod + async def aall(self) -> List[dict]: + """Retrieve all cached items asynchronously""" + raise NotImplementedError("Async all must be implemented by subclass") + + @abstractmethod + def all_length(self) -> int: + """Get total number of cached items""" + raise NotImplementedError("All_length must be implemented by subclass") + + @abstractmethod + async def aall_length(self) -> int: + """Get total number of cached items asynchronously""" + raise NotImplementedError("Async all_length must be implemented by subclass") + + @abstractmethod + def random(self, size: int) -> List[CacheResponse]: + """Get random cached items""" + raise NotImplementedError("Random must be implemented by subclass") + + @abstractmethod + async def arandom(self, size: int) -> List[CacheResponse]: + """Get random cached items asynchronously""" + raise NotImplementedError("Async random must be implemented by subclass") + + @abstractmethod + def pull(self, key: str) -> Union[CacheResponse, None]: + """Retrieve item from cache""" + raise NotImplementedError("Pull must be implemented by subclass") + + @abstractmethod + async def apull(self, key: str) -> Union[CacheResponse, None]: + """Retrieve item from cache asynchronously""" + raise NotImplementedError("Async pull must be implemented by subclass") + + @abstractmethod + def push(self, key: str, value: Union[str, dict]) -> None: + """Store item in cache""" + raise NotImplementedError("Push must be implemented by subclass") + + @abstractmethod + async def apush(self, key: str, value: Union[str, dict]) -> None: + """Store item in cache asynchronously""" + raise NotImplementedError("Async push must be implemented by subclass") + + @abstractmethod + def delete(self, key: str) -> None: + """Remove item from cache""" + raise NotImplementedError("Delete must be implemented by subclass") + + @abstractmethod + async def adelete(self, key: str) -> None: + """Remove item from cache asynchronously""" + raise NotImplementedError("Async delete must be implemented by subclass") + + @abstractmethod + def close(self) -> None: + """Close database connection""" + raise NotImplementedError("Close must be implemented by subclass") + + @abstractmethod + async def aclose(self) -> None: + """Close database connection asynchronously""" + raise NotImplementedError("Async close must be implemented by subclass") diff --git a/freedium-library/src/freedium_library/utils/cache/db/mongo.py b/freedium-library/src/freedium_library/utils/cache/db/mongo.py new file mode 100644 index 0000000..130c3e6 --- /dev/null +++ b/freedium-library/src/freedium_library/utils/cache/db/mongo.py @@ -0,0 +1,139 @@ +from typing import Union + +import pymongo +from motor.motor_asyncio import AsyncIOMotorClient + +from freedium_library.utils.json import json + +from ..models import CacheResponse +from .base import AbstractCacheBackend + + +class MongoDBCacheBackend(AbstractCacheBackend): + def __init__( + self, + connection_string: str, + database: str = "freedium_cache", + collection: str = "cache", + ): + self.connection_string = connection_string + self.database_name = database + self.collection_name = collection + self.client = None + self.db = None + self.collection = None + self.connect() + + def connect(self): + self.client = pymongo.MongoClient(self.connection_string) + self.db = self.client[self.database_name] + self.collection = self.db[self.collection_name] + + def ensure_connection(self): + if self.client is None: + self.connect() + + def init_db(self): + self.ensure_connection() + self.collection.create_index("key", unique=True) + + def all(self): + self.ensure_connection() + return list(self.collection.find()) + + def all_length(self) -> int: + self.ensure_connection() + return self.collection.count_documents({}) + + def random(self, size: int) -> list[CacheResponse]: + self.ensure_connection() + pipeline = [{"$sample": {"size": size}}] + results = self.collection.aggregate(pipeline) + return [CacheResponse(doc["key"], doc["value"]) for doc in results] + + def pull(self, key: str) -> Union[CacheResponse, None]: + self.ensure_connection() + doc = self.collection.find_one({"key": key}) + if doc: + logger.debug("Value found in DB, returning it") + return CacheResponse(key, doc["value"]) + logger.debug(f"No value found for key: {key}") + return None + + def push(self, key: str, value: Union[str, dict]) -> None: + if isinstance(value, dict): + value = json.dumps(value) + elif not isinstance(value, str): + raise ValueError( + f"value argument should be a string or dict, not {type(value).__name__}" + ) + + self.ensure_connection() + self.collection.update_one( + {"key": key}, {"$set": {"key": key, "value": value}}, upsert=True + ) + + def delete(self, key: str) -> None: + self.ensure_connection() + result = self.collection.delete_one({"key": key}) + if result.deleted_count > 0: + logger.debug(f"Deleted key: {key}") + else: + logger.warning(f"Attempted to delete non-existing key: {key}") + + def close(self): + if self.client: + self.client.close() + self.client = None + self.db = None + self.collection = None + + +class AsyncMongoDBCacheBackend(AbstractCacheBackend): + def __init__( + self, + connection_string: str, + database: str = "cache_db", + collection: str = "cache", + ): + self.connection_string = connection_string + self.database_name = database + self.collection_name = collection + self.client = None + self.db = None + self.collection = None + self.connect() + + def connect(self): + self.client = AsyncIOMotorClient(self.connection_string) + self.db = self.client[self.database_name] + self.collection = self.db[self.collection_name] + + async def aensure_connection(self): + if self.client is None: + self.connect() + + async def ainit_db(self): + await self.aensure_connection() + await self.collection.create_index("key", unique=True) + + async def aall(self): + await self.aensure_connection() + return await self.collection.find().to_list(None) + + async def aall_length(self) -> int: + await self.aensure_connection() + return await self.collection.count_documents({}) + + async def arandom(self, size: int) -> list[CacheResponse]: + await self.aensure_connection() + pipeline = [{"$sample": {"size": size}}] + results = await self.collection.aggregate(pipeline).to_list(None) + return [CacheResponse(doc["key"], doc["value"]) for doc in results] + + async def aclose(self): + if self.client: + self.client.close() + self.client = None + self.db = None + self.collection = None diff --git a/freedium-library/src/freedium_library/utils/json.py b/freedium-library/src/freedium_library/utils/json.py index ad3e58a..2b65972 100644 --- a/freedium-library/src/freedium_library/utils/json.py +++ b/freedium-library/src/freedium_library/utils/json.py @@ -128,3 +128,4 @@ class JSON: json_instance = JSON() +json = json_instance diff --git a/freedium-library/src/freedium_library/utils/meta/pydantic.py b/freedium-library/src/freedium_library/utils/meta/pydantic.py index 50b926e..e7c9613 100644 --- a/freedium-library/src/freedium_library/utils/meta/pydantic.py +++ b/freedium-library/src/freedium_library/utils/meta/pydantic.py @@ -1,5 +1,5 @@ from functools import partial -from typing import cast +from typing import Type, cast from pydantic_settings import BaseSettings as _BaseSettings from pydantic_settings import SettingsConfigDict as _SettingsConfigDict @@ -8,8 +8,8 @@ from pydantic_settings import SettingsConfigDict as _SettingsConfigDict class BaseConfig(_BaseSettings): ... -BaseSettingsConfigDict: _SettingsConfigDict = cast( - _SettingsConfigDict, +BaseSettingsConfigDict: Type[_SettingsConfigDict] = cast( + Type[_SettingsConfigDict], partial( _SettingsConfigDict, env_file=".env", diff --git a/medium-parser/medium_parser/bypass_cloudflare.py b/medium-parser/medium_parser/bypass_cloudflare.py deleted file mode 100644 index 3621e94..0000000 --- a/medium-parser/medium_parser/bypass_cloudflare.py +++ /dev/null @@ -1,37 +0,0 @@ -# source: https://github.com/thorio/KGrabber/issues/35#issuecomment-1164056688 -# curl -L --user-agent "Mozilla/5.0" --cipher AES256-SHA256 --tls-max 1.2 "" - -import sys - -async def main(): - # Create a default SSL context - ssl_context = ssl.create_default_context() - - # Disable TLS versions older than 1.2 - ssl_context.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 | ssl.OP_NO_TLSv1_3 # ??? - --tls-max - - # Set cipher suite - ssl_context.set_ciphers('AES256-SHA') - - headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0'} - - # Configure aiohttp ClientSession with custom SSL context and User-Agent - async with aiohttp.ClientSession(headers=headers) as session: - async with session.get('https://github.zhaoqumao.com/veggiemonk/awesome-docker', ssl=ssl_context) as response: - print(await response.text()) - print(response.status) - - -### - -# Port to Python 3.10+ -# https://github.com/fedora-infra/fedora-messaging/pull/250/commits/f3439350de7f55d42f4350e662392e9ffbdde028 - -ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH) # ??? - purpose -if sys.version_info >= (3, 7): - ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 -else: - ssl_context.options |= ssl.OP_NO_SSLv2 - ssl_context.options |= ssl.OP_NO_SSLv3 - ssl_context.options |= ssl.OP_NO_TLSv1 - ssl_context.options |= ssl.OP_NO_TLSv1_1 diff --git a/new-web/src/routes/+error.svelte b/new-web/+error.svelte similarity index 100% rename from new-web/src/routes/+error.svelte rename to new-web/+error.svelte diff --git a/new-web/bun.lockb b/new-web/bun.lockb index f3b54dc..ddd5f1e 100755 Binary files a/new-web/bun.lockb and b/new-web/bun.lockb differ diff --git a/new-web/package.json b/new-web/package.json index 9952319..06900bc 100644 --- a/new-web/package.json +++ b/new-web/package.json @@ -18,6 +18,7 @@ "@iconify-json/mdi": "^1.2.1", "@iconify-json/simple-icons": "^1.2.12", "@iconify-json/teenyicons": "^1.2.1", + "@shikijs/rehype": "^1.24.2", "@sveltejs/adapter-auto": "^3.3.1", "@sveltejs/kit": "^2.7.4", "@sveltejs/vite-plugin-svelte": "^3.1.2", @@ -49,6 +50,7 @@ "hast-util-to-html": "^9.0.3", "mdsvex": "^0.12.3", "medium-zoom": "^1.1.0", + "ofetch": "^1.4.1", "rehype-external-links": "^3.0.0", "shiki": "^1.23.1", "shiki-transformer-copy-button": "^0.0.3", diff --git a/new-web/src/api.js b/new-web/src/api.js new file mode 100644 index 0000000..f368f9c --- /dev/null +++ b/new-web/src/api.js @@ -0,0 +1,8 @@ +import { ofetch } from "ofetch"; +import config from "./config"; + +const apiFetch = ofetch.create({ + baseURL: config.API_URL, +}); + +export default apiFetch; diff --git a/new-web/src/config.js b/new-web/src/config.js new file mode 100644 index 0000000..c68a072 --- /dev/null +++ b/new-web/src/config.js @@ -0,0 +1,7 @@ +import { env } from "$env/dynamic/public"; + +const API_URL = env.VITE_API_URL || "http://localhost:7080/api"; + +export default { + API_URL, +}; diff --git a/new-web/src/lib/codeCopy.js b/new-web/src/lib/codeCopy.js new file mode 100644 index 0000000..d2a5f71 --- /dev/null +++ b/new-web/src/lib/codeCopy.js @@ -0,0 +1,32 @@ +export function initializeCodeCopyButtons() { + document.querySelectorAll(".code-copy-btn").forEach((button) => { + button.addEventListener("click", handleCodeCopy); + }); +} + +function handleCodeCopy(event) { + const button = event.currentTarget; + if (button.classList.contains("copied")) return; + + const code = button.dataset.code; + const toggleMs = parseInt(button.dataset.toggleMs, 10); + const ready = button.querySelector(".ready"); + const success = button.querySelector(".success"); + + navigator.clipboard.writeText(code); + button.classList.add("copied"); + ready.style.display = "none"; + success.style.display = "block"; + + setTimeout(() => { + button.classList.remove("copied"); + ready.style.display = "block"; + success.style.display = "none"; + }, toggleMs); + + window.dispatchEvent( + new CustomEvent("toast", { + detail: { message: "Copied to clipboard" }, + }), + ); +} diff --git a/new-web/src/lib/elements/ArticlePage.css b/new-web/src/lib/elements/ArticlePage.css new file mode 100644 index 0000000..25a037b --- /dev/null +++ b/new-web/src/lib/elements/ArticlePage.css @@ -0,0 +1,207 @@ +/* Headings */ +.prose h1 { + @apply mt-12 mb-6 text-4xl font-bold text-gray-900 dark:text-gray-100; +} + +.prose h2 { + @apply mt-12 mb-4 text-3xl font-bold text-gray-900 dark:text-gray-100; +} + +.prose h3 { + @apply mt-8 mb-3 text-2xl font-bold text-gray-900 dark:text-gray-100; +} + +.prose h4 { + @apply mt-6 mb-2 text-xl font-bold text-gray-900 dark:text-gray-100; +} + +.prose h5 { + @apply mt-4 mb-2 text-lg font-bold text-gray-900 dark:text-gray-100; +} + +.prose h6 { + @apply mt-4 mb-2 text-base font-bold text-gray-900 dark:text-gray-100; +} + +/* Paragraphs and spacing */ +.prose p { + @apply mt-4 leading-7 text-gray-700 dark:text-gray-300; +} + +.prose > *:first-child { + @apply mt-0; +} + +/* Lists */ +.prose ul { + @apply mt-4 ml-6 space-y-2 list-disc list-outside; +} + +.prose ol { + @apply mt-4 ml-6 space-y-2 list-decimal list-outside; +} + +.prose li { + @apply leading-7 text-gray-700 dark:text-gray-300; +} + +/* Blockquotes */ +.prose blockquote { + @apply pl-4 my-6 italic border-l-4 border-gray-300 dark:border-gray-700; +} + +/* Code blocks */ +.prose pre { + @apply p-4 my-6 overflow-x-auto rounded-lg; +} + +.prose code:not(.shiki code) { + @apply px-1.5 py-0.5 rounded text-sm font-mono bg-gray-200 dark:bg-zinc-700; +} + +.prose pre code { + @apply p-0 text-sm leading-relaxed bg-transparent; +} + +/* Tables */ +.prose table { + @apply w-full my-6 border-collapse; +} + +.prose th { + @apply px-4 py-2 text-left bg-gray-100 border border-gray-300 dark:border-gray-700 dark:bg-zinc-800; +} + +.prose td { + @apply px-4 py-2 border border-gray-300 dark:border-gray-700; +} + +/* Links */ +.prose a { + @apply underline text-primary hover:text-primary/90 decoration-primary/30 hover:decoration-primary/50; +} + +.prose a::after { + content: ""; + @apply icon-[heroicons-outline--external-link] inline-block ml-1 w-3.5 h-3.5 align-text-bottom; +} + +/* Horizontal rule */ +.prose hr { + @apply my-8 border-gray-200 dark:border-gray-700; +} + +/* Images */ +.prose img { + @apply mx-auto my-6 rounded-lg; +} + +/* Inline elements */ +.prose strong { + @apply font-bold text-gray-900 dark:text-gray-100; +} + +.prose em { + @apply italic; +} + +.prose del { + @apply line-through; +} + +/* Definition lists */ +.prose dl { + @apply mt-4; +} + +.prose dt { + @apply font-bold text-gray-900 dark:text-gray-100; +} + +.prose dd { + @apply mt-2 ml-4; +} + +/* Nested lists spacing */ +.prose li > ul, +.prose li > ol { + @apply mt-2 mb-2; +} + +/* Code block filename */ +.prose .filename { + @apply mt-6 -mb-2 text-sm text-gray-500 dark:text-gray-400; +} + +/* Summary blocks */ +.prose summary { + @apply leading-7 text-gray-700 cursor-pointer select-none dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100; +} + +.prose details { + @apply p-4 my-4 rounded-lg bg-gray-50 dark:bg-zinc-800; +} + +.prose details[open] summary { + @apply mb-3; +} + +/* Aside blocks */ +.prose aside { + @apply p-4 my-6 border-l-4 rounded-lg border-primary bg-primary/5; +} + +.prose aside p { + @apply m-0; +} + +/* Mark blocks */ +.prose mark { + @apply bg-primary/20 text-primary px-1.5 py-0.5 rounded; +} + +/* Keyboard blocks */ +.prose kbd { + @apply px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg dark:bg-gray-600 dark:text-gray-100 dark:border-gray-500; +} + +/* Mixtape embeds */ +.prose .mixtape-embed { + @apply items-center p-2 overflow-hidden border border-gray-300 mt-7; +} + +.prose .mixtape-content { + @apply flex flex-row justify-between p-2 overflow-hidden; +} + +.prose .mixtape-text { + @apply flex flex-col justify-center p-2; +} + +.prose .mixtape-text h2 { + @apply text-base font-bold text-black dark:text-gray-100; +} + +.prose .mixtape-description { + @apply block mt-2; +} + +.prose .mixtape-description h3 { + @apply text-sm text-gray-700; +} + +.prose .mixtape-site { + @apply mt-5; +} + +.prose .mixtape-site p { + @apply text-xs text-gray-700; +} + +.prose .mixtape-image { + @apply relative flex flex-row h-40 w-60; +} + +.prose .mixtape-image-inner { + @apply absolute inset-0 bg-center bg-cover; +} diff --git a/new-web/src/lib/elements/ArticlePage.svelte b/new-web/src/lib/elements/ArticlePage.svelte index c7c832f..afe7a83 100644 --- a/new-web/src/lib/elements/ArticlePage.svelte +++ b/new-web/src/lib/elements/ArticlePage.svelte @@ -1,45 +1,19 @@ - {data.title} - Freedium + {article.title} - Freedium @@ -79,35 +53,31 @@ {:else} - {#if data.postImage} + {#if article.postImage} {/if}
-

{formatDate(data.date)}

-

{data.title}

+

{formatDate(article.date)}

+

{article.title}

- +
-

{data.author.name}

-

{data.author.role}

+

{article.author.name}

+

{article.author.role}

-
+
- {#if contentLoaded} - {#if compiledContent} - - {:else} -

Error loading content

- {/if} + {#if content} + {@html content} {:else} -

Loading content...

+

Error loading content

{/if}
@@ -132,9 +102,9 @@

Contents

- {#if data.tableOfContents && data.tableOfContents.length > 0} + {#if article.tableOfContents && article.tableOfContents.length > 0}
{/if} + + diff --git a/new-web/src/routes/+page.server.js b/new-web/src/routes/+page.server.js new file mode 100644 index 0000000..cbc6d96 --- /dev/null +++ b/new-web/src/routes/+page.server.js @@ -0,0 +1,39 @@ +import { render } from "@/services"; +import { compile } from "mdsvex"; + +export async function load({ params }) { + try { + const transformed = await render("medium"); + const { code } = await compile(transformed.text); + + return { + content: code, + article: { + title: "UploadThing is 5x Faster", + date: "2024-09-13T12:00:00Z", + author: { + name: "Theo Browne", + role: "CEO @ Ping Labs", + avatar: "https://picsum.photos/seed/post1/400/300", + }, + postImage: "https://picsum.photos/seed/postimage/1200/600", + tableOfContents: [ + { id: "v7-is-here", title: "V7 Is Here!" }, + { id: "benchmarks", title: "Benchmarks" }, + { id: "the-road-to-v7", title: "The Road To V7" }, + { id: "uploadthing-has-served", title: "UploadThing Has Served..." }, + { + id: "and-were-just-getting-started", + title: "...and we're just getting started", + }, + ], + }, + }; + } catch (error) { + return { + content: null, + article: null, + error: "Failed to load article", + }; + } +} diff --git a/new-web/src/routes/[slug]/+page.js b/new-web/src/routes/[slug]/+page.js deleted file mode 100644 index 16cc4f4..0000000 --- a/new-web/src/routes/[slug]/+page.js +++ /dev/null @@ -1,5 +0,0 @@ -export const load = ({ params }) => { - return { - slug: params.slug - }; -}; diff --git a/new-web/src/routes/[slug]/+page.server.js b/new-web/src/routes/[slug]/+page.server.js new file mode 100644 index 0000000..586c006 --- /dev/null +++ b/new-web/src/routes/[slug]/+page.server.js @@ -0,0 +1,149 @@ +import { render } from "@/services"; +import { compile } from "mdsvex"; +import { createHighlighter } from "shiki"; +import { toHtml } from "hast-util-to-html"; +import { h } from "hastscript"; + +const HIGHLIGHT_CONFIG = { + themes: ["github-light", "github-dark"], + langs: ["javascript", "typescript"], +}; + +const CODE_ATTRIBUTES = { + contenteditable: "true", + "aria-label": "code", + "aria-readonly": "true", + inputmode: "none", + tabindex: "0", + "aria-multiline": "true", + "aria-haspopup": "false", + "data-gramm": "false", + "data-gramm_editor": "false", + "data-enable-grammarly": "false", + spellcheck: "false", + autocorrect: "off", + autocapitalize: "none", + autocomplete: "off", + "data-ms-editor": "false", +}; + +const MOCK_ARTICLE = { + title: "UploadThing is 5x Faster", + date: "2024-09-13T12:00:00Z", + author: { + name: "Theo Browne", + role: "CEO @ Ping Labs", + avatar: "https://picsum.photos/seed/post1/400/300", + }, + postImage: "https://picsum.photos/seed/postimage/1200/600", + tableOfContents: [ + { id: "v7-is-here", title: "V7 Is Here!" }, + { id: "benchmarks", title: "Benchmarks" }, + { id: "the-road-to-v7", title: "The Road To V7" }, + { id: "uploadthing-has-served", title: "UploadThing Has Served..." }, + { + id: "and-were-just-getting-started", + title: "...and we're just getting started", + }, + ], +}; + +function createCodeCopyButton(code, toggleMs = 3000) { + const lineCount = code.split("\n").length; + const positionClass = lineCount <= 3 ? "top-1/2 -translate-y-1/2" : "top-3"; + + const button = h( + "button", + { + "data-code": code, + "data-toggle-ms": toggleMs, + class: `code-copy-btn absolute right-4 ${positionClass} h-8 w-8 p-1.5 flex items-center justify-center bg-black/50 rounded-md transition-colors duration-200 cursor-pointer hover:bg-black/70`, + }, + [ + h("span", { + class: + "ready text-white w-full aspect-square bg-no-repeat bg-center bg-cover z-50 block icon-[heroicons--clipboard-document]", + }), + h("span", { + class: + "success w-full text-white aspect-square bg-no-repeat bg-center bg-cover z-50 hidden icon-[heroicons--clipboard-document-check-16-solid]", + }), + ], + ); + + return toHtml(button); +} + +async function createHighlightedCode(code, lang = "text") { + const highlighter = await createHighlighter(HIGHLIGHT_CONFIG); + await highlighter.loadLanguage("javascript", "typescript"); + + const lightHtml = highlighter.codeToHtml(code, { + lang, + theme: "github-light", + transformers: [ + { + code(node) { + node.properties = { ...node.properties, ...CODE_ATTRIBUTES }; + return node; + }, + }, + ], + }); + + const darkHtml = highlighter.codeToHtml(code, { + lang, + theme: "github-dark", + transformers: [ + { + code(node) { + node.properties = { ...node.properties, ...CODE_ATTRIBUTES }; + return node; + }, + }, + ], + }); + + const buttonHtml = createCodeCopyButton(code, 1200); + + return ` +
+ ${buttonHtml} +
${lightHtml}
+ +
+ `; +} + +export async function load({ params }) { + try { + const transformed = await render("medium"); + const { code } = await compile(transformed.text, { + highlight: { + highlighter: createHighlightedCode, + }, + }); + + return { + slug: params.slug, + loading: false, + content: code, + article: MOCK_ARTICLE, + }; + } catch (error) { + console.error("Failed to load article:", error); + return { + slug: params.slug, + loading: false, + content: null, + article: { + title: "Article Not Found", + date: new Date().toISOString(), + author: { name: "Unknown", role: "", avatar: "" }, + postImage: null, + tableOfContents: [], + }, + error: error.message, + }; + } +} diff --git a/new-web/src/routes/[slug]/+page.svelte b/new-web/src/routes/[slug]/+page.svelte index 010144c..3f0d698 100644 --- a/new-web/src/routes/[slug]/+page.svelte +++ b/new-web/src/routes/[slug]/+page.svelte @@ -1,5 +1,13 @@ - + diff --git a/new-web/src/routes/api/services/[service]/render/+server.js b/new-web/src/routes/api/services/[service]/render/+server.js new file mode 100644 index 0000000..8327b00 --- /dev/null +++ b/new-web/src/routes/api/services/[service]/render/+server.js @@ -0,0 +1,35 @@ +import { json } from "@sveltejs/kit"; +import { compile } from "mdsvex"; + +const mdsvexConfig = { + extensions: [".svx", ".md"], + smartypants: { + dashes: "oldschool", + }, + remarkPlugins: [], + rehypePlugins: [], +}; + +export async function GET({ params }) { + try { + const { service } = params; + + // Example content - replace with your actual content fetching + const markdown = ` +# Test Content ${service} +This is a test article. + `; + + const compiled = await compile(markdown, mdsvexConfig); + + if (!compiled) { + throw new Error("Failed to compile markdown"); + } + + return json({ + text: compiled.code, + }); + } catch (error) { + return json({ error: error.message }, { status: 500 }); + } +} diff --git a/new-web/src/services/index.js b/new-web/src/services/index.js new file mode 100644 index 0000000..b4d10cd --- /dev/null +++ b/new-web/src/services/index.js @@ -0,0 +1 @@ +export { render } from "./render"; diff --git a/new-web/src/services/render.js b/new-web/src/services/render.js new file mode 100644 index 0000000..8ae2f53 --- /dev/null +++ b/new-web/src/services/render.js @@ -0,0 +1,8 @@ +import apiFetch from "@/api"; + +async function render(serviceName) { + const response = await apiFetch(`/services/${serviceName}/render`); + return response; +} + +export { render }; diff --git a/new-web/svelte.config.js b/new-web/svelte.config.js index 5c5a741..c2de4d7 100644 --- a/new-web/svelte.config.js +++ b/new-web/svelte.config.js @@ -1,11 +1,12 @@ import { mdsvex, escapeSvelte } from "mdsvex"; -import rehypeExternalLinks from "rehype-external-links"; import { createHighlighter } from "shiki"; import adapter from "@sveltejs/adapter-auto"; import { toHtml } from "hast-util-to-html"; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; import { h } from "hastscript"; import remarkMixtapeEmbed from "./src/lib/utils/remark/mixtape-embed.js"; +import rehypeExternalLinks from "rehype-external-links"; +import rehypeShiki from "@shikijs/rehype"; const readySvg = "data:image/svg+xml;utf8,"; @@ -95,6 +96,7 @@ const mdsvexOptions = { remarkPlugins: [remarkMixtapeEmbed], rehypePlugins: [ [rehypeExternalLinks, { target: "_blank", rel: ["nofollow"] }], + [rehypeShiki, { theme: "poimandres" }], ], highlight: { highlighter: async (code, lang = "text") => { @@ -134,10 +136,8 @@ const mdsvexOptions = { }); const resultHtml = toHtml(result); const buttonHtml = renderCodeCopyButton(code, { toggle: 1200 }); - const html = escapeSvelte( - `
${buttonHtml}${resultHtml}
`, - ); - return `{@html \`${html}\` }`; + const html = `
${buttonHtml}${resultHtml}
`; + return html; }, }, }; diff --git a/new-web/vite.config.ts b/new-web/vite.config.ts index ad2d48b..be148ff 100644 --- a/new-web/vite.config.ts +++ b/new-web/vite.config.ts @@ -1,12 +1,13 @@ -import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; -import path from 'path'; +import { sveltekit } from "@sveltejs/kit/vite"; +import { defineConfig } from "vite"; +import path from "path"; export default defineConfig({ plugins: [sveltekit()], resolve: { alias: { - $lib: path.resolve('./src/lib') - } - } + $lib: path.resolve("./src/lib"), + "@": path.resolve("./src"), + }, + }, });