diff --git a/HOW_TO_CONTRIBUTE.md b/HOW_TO_CONTRIBUTE.md new file mode 100644 index 0000000..b0852e3 --- /dev/null +++ b/HOW_TO_CONTRIBUTE.md @@ -0,0 +1,83 @@ +# How to Contribute a New OAuth Service + +## 1. Create a Config Model + +In your service module, create a `Config` Pydantic model that defines the OAuth configuration: + +```python +from pydantic import BaseModel + +class Config(BaseModel): + service: str = "google" + client_id: str + client_secret: str + auth_base: str = "..." + token_url: str = "..." + api_base: str = "..." + api_resource: str = "..." + profile_endpoint: str = "..." + redirect_uri: str = "..." + scope: str = "..." + pkce: bool = True +``` + +* Set `service` to a unique identifier for the platform. +* Specify the authorization URL (`auth_base`), token URL (`token_url`), API endpoints, and scopes. +* `redirect_uri` should point to your API route for handling the callback. + +## 2. Instantiate the OAuth Provider + +Use the shared `OAuthProvider` class: + +```python +from fastapi import APIRouter +import pathlib +from .oauth_base import OAuthProvider + +router = APIRouter(prefix="/[my_service]", tags=["[my_service]"]) + +provider = OAuthProvider( + package=__package__, + config_model=Config, + icon=(pathlib.Path(__file__).parent / "icon.svg").read_text() +) +``` + +* The `icon` will be displayed in the frontend service cards. + +## 3. Add API Routes + +Define FastAPI routes to handle connecting, authentication, token refresh, and user info: + +```python +@router.get("/connect") +async def google_connect(token: str, platform: str): + return await provider.connect(token, platform) + +@router.get("/auth") +async def google_auth(code: str, state: str, db=Depends(get_session)): + return await provider.auth(code, state, db) + +@router.get("/refresh") +async def google_refresh(user=Depends(get_current_user), db=Depends(get_session)): + return await provider.refresh(user, db) + +@router.get("/me") +async def google_me(user=Depends(get_current_user), db=Depends(get_session)): + return await provider.me(user, db) +``` + +* `/connect` initiates the OAuth connection. +* `/auth` handles the callback from the OAuth provider. +* `/refresh` refreshes the access token. +* `/me` retrieves the current user’s profile info from the service. + +## 4. Add Service Icon + +Place an SVG icon named `icon.svg` in the service module folder. This will be displayed in the frontend cards for connecting services. + +## 5. Test + +1. Login in to the area +2. Go to `/services` +3. You should see you newly added service and be able to connect to it diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab92c9e --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# Action-Reaction + +## Create an Automation Platform (similar to IFTTT / Zapier) + +## πŸ“Œ Overview + +Action-Reaction is an automation platform designed to connect services together. +Users can define **AREAs** (*Action + REAction*) that automatically execute when certain events occur. + +The system is composed of three main parts: + +- **Application Server**: Business logic & REST API. +- **Web Client**: Browser-based UI, communicates with the server. +- **Mobile Client**: Android app, communicates with the server. + +--- + +## ✨ Features + +- User registration & authentication (password-based + OAuth2). +- Service subscription (Google, Outlook, Dropbox, etc.). +- Action components (event triggers). +- REAction components (automated tasks). +- AREAs: link Actions to REActions. +- Hooks: monitor & trigger automation. + +--- + +## πŸ— Architecture + +- **Server**: Runs business logic, exposes REST API (`http://localhost:8080`). +- **Web Client**: User interface (`http://localhost:8081`). +- **Mobile Client**: Android application, distributed via APK. +- **Docker Compose**: Orchestration of all components. + +--- + +## πŸš€ Getting Started + +### Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) +- [Docker Compose](https://docs.docker.com/compose/) + +### Installation + + - **Step 1**: Go to back and a create a \`config.toml\` file. Fill it based on the data in exemples/exemple_config: + The `jwt_secret` is an ascii string. + Create an `uri` (or copy for exemples/exemple_config) and fill it with `"sqlite+aiosqlite:///app.db"`. + Each routes is defined following that structure: [routes.{service}]. The list of services can be found in back/app/routes. + For each routes fill the `client_id` and `client_secret` with your own. + Note for caldav/gmail/youtube, use the same `client_id`/`client_secret`. The `client_id` must finish with `.apps.googleusercontent.com`. + + +- **Step 2**: Go to front and create a \`gradle.properties\` file. Fill it with the informations in exemples/exemple_gradle. Fill `RELEASE_STORE_PASSWORD` and `RELEASE_KEY_PASSWORD` with your own password. The two must have an identical one. + +- **Step 3**: In your terminal run `keytool -genkey -v -keystore apk_key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias alias;`. +It will ask for a keystore password, put the one you chose for the second step. It will follow by asking more information; those information don't need to be necesarilly true. +Enter 'y' to confirm the datas you entered. + +- **Step 4**: Run `docker compose up --build` + + +### Services + +- Server -> `http://localhost:8080/about.json` +- Web Client -> `http://localhost:8081/` +- Mobile Client APK -> YES + +--- + +## πŸ“œ API Example: `about.json` + +WIP + +--- + +## πŸ“… Project Timeline + +- **21/09/2025**: Tech stack selection, PoC, task distribution. +- **06/10/2025**: Core architecture & base functionality. +- **02/11/2025**: Full feature set, UI, Docker deployment. + +--- + +## πŸ“– Documentation + +- **API**: http://localhost:8080/docs diff --git a/back/Dockerfile b/back/Dockerfile index 6f40538..a469405 100644 --- a/back/Dockerfile +++ b/back/Dockerfile @@ -21,10 +21,11 @@ FROM scratch # Copy /nix/store and the built result COPY --from=builder /tmp/nix-store-closure /nix/store COPY --from=builder /tmp/build/result /app +COPY --from=builder /tmp/build/back/config.toml /app/config.toml # Expose server port and run ENV PORT=8080 WORKDIR /app EXPOSE 8080 # Pass "dev" to enable the CORS branch in app.main -CMD ["/app/bin/area", "dev"] +CMD ["/app/bin/area", "run"] diff --git a/back/app/db/models/__init__.py b/back/app/db/models/__init__.py index cfa007c..f81f996 100644 --- a/back/app/db/models/__init__.py +++ b/back/app/db/models/__init__.py @@ -1,4 +1,15 @@ +from .interaction import Interaction +from .oauth import OAuthToken +from .service import Service from .user import User from .workflow import Workflow, WorkflowNode, WorkflowNodeConfig -__all__ = ("User", "Workflow", "WorkflowNode", "WorkflowNodeConfig") +__all__ = ( + "Interaction", + "OAuthToken", + "Service", + "User", + "Workflow", + "WorkflowNode", + "WorkflowNodeConfig", +) diff --git a/back/app/db/models/interaction.py b/back/app/db/models/interaction.py new file mode 100644 index 0000000..76fdaca --- /dev/null +++ b/back/app/db/models/interaction.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, Integer, String + +from ..base import Base + + +class Interaction(Base): + __tablename__ = "interaction" + + id = Column(Integer, primary_key=True) + name = Column(String(64), nullable=False) diff --git a/back/app/db/models/oauth.py b/back/app/db/models/oauth.py index 50945b2..0faac11 100644 --- a/back/app/db/models/oauth.py +++ b/back/app/db/models/oauth.py @@ -1,19 +1,19 @@ -from datetime import datetime - -from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String from sqlalchemy.orm import relationship from ..base import Base -class UserToken(Base): - __tablename__ = "user_tokens" +class OAuthToken(Base): + __tablename__ = "oauth_token" id = Column(Integer, primary_key=True) - user_id = Column(ForeignKey("user.id"), nullable=False) - service = Column(String(100), nullable=False) # e.g. "spotify" - access_token = Column(Text, nullable=False) - refresh_token = Column(Text, nullable=True) + owner_id = Column(ForeignKey("user.id"), nullable=False) + access_token = Column(String(128), nullable=False) + refresh_token = Column(String(128), nullable=True) + + service_id = Column(Integer, ForeignKey("service.id"), nullable=False) + scope = Column(String(512), nullable=True) expires_at = Column(DateTime, nullable=True) diff --git a/back/app/db/models/service.py b/back/app/db/models/service.py new file mode 100644 index 0000000..cb55c55 --- /dev/null +++ b/back/app/db/models/service.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, Integer, String + +from ..base import Base + + +class Service(Base): + __tablename__ = "service" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(32), index=True) diff --git a/back/app/db/models/user.py b/back/app/db/models/user.py index bdecc50..741749d 100644 --- a/back/app/db/models/user.py +++ b/back/app/db/models/user.py @@ -10,11 +10,14 @@ class User(Base): __tablename__ = "user" id = Column(Integer, primary_key=True, index=True) - email = Column(String, unique=True, index=True, nullable=False) - auth = Column(String, nullable=False) - name = Column(String, nullable=False) + name = Column(String(64), nullable=False) + + email = Column(String(256), unique=True, index=True, nullable=False) + auth = Column(String(256), nullable=False) + created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + workflows = relationship( "Workflow", back_populates="owner", @@ -23,7 +26,7 @@ class User(Base): ) tokens = relationship( - "UserToken", + "OAuthToken", back_populates="user", cascade="all, delete-orphan", passive_deletes=True, diff --git a/back/app/db/models/workflow.py b/back/app/db/models/workflow.py index 371ab84..a913a7d 100644 --- a/back/app/db/models/workflow.py +++ b/back/app/db/models/workflow.py @@ -8,8 +8,9 @@ class Workflow(Base): __tablename__ = "workflow" id = Column(Integer, primary_key=True, index=True) - name = Column(String, index=True) - description = Column(String, index=True, nullable=True) + name = Column(String(32), index=True) + + description = Column(String(512), index=True, nullable=True) owner_id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE")) nodes = relationship( @@ -30,6 +31,8 @@ class WorkflowNode(Base): Integer, ForeignKey("workflow.id", ondelete="CASCADE") ) + interaction_id = Column(Integer, ForeignKey("interaction.id")) + config = relationship( "WorkflowNodeConfig", back_populates="node", @@ -66,37 +69,15 @@ class WorkflowNodeConfig(Base): node_id = Column( Integer, ForeignKey("workflow_node.id", ondelete="CASCADE") ) - key = Column(String, index=True) - value = Column(String, index=True) + + key = Column(String(32), index=True) + value = Column(String(128), index=True) node = relationship("WorkflowNode", back_populates="config") class WorkflowEdge(Base): - """ - Represents a directed connection between two WorkflowNode entities in the workflow graph. - - This SQLAlchemy ORM model stores edges that originate from one node and point to another. - A uniqueness constraint on `to_node_id` guarantees that a node can have at most one - incoming edge, while a single node may emit multiple outgoing edges. Deleting a node - cascades to its associated edges. - - Attributes: - id (int): Primary key identifier of the edge. - from_node_id (int): Foreign key to the source WorkflowNode (workflow_node.id), cascades on delete. - to_node_id (int): Foreign key to the target WorkflowNode (workflow_node.id), cascades on delete. - Uniquely constrained to enforce at most one incoming edge per node. - - Relationships: - from_node (WorkflowNode): Source node; back-populates 'outgoing_edges'. - to_node (WorkflowNode): Target node; back-populates 'incoming_edge'. - - Constraints and behavior: - - Directed edge: from_node -> to_node. - - ondelete="CASCADE" ensures edges are removed when their associated nodes are deleted. - - Unique to_node_id enforces in-degree <= 1, enabling a one-to-many (source->edges) and - one-to-one (target<-edge) structure. - """ + """Represents a directed connection between two WorkflowNode its graph.""" __tablename__ = "workflow_edge" diff --git a/back/app/main.py b/back/app/main.py index 17bb1a1..2577a06 100644 --- a/back/app/main.py +++ b/back/app/main.py @@ -33,4 +33,4 @@ async def lifespan(_: FastAPI): def main(): - uvicorn.run(app, host="127.0.0.1", port=8080) + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/back/app/routes/caldav/google.py b/back/app/routes/caldav/google.py index 09efd72..d24bc03 100644 --- a/back/app/routes/caldav/google.py +++ b/back/app/routes/caldav/google.py @@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from ...db import get_session -from ...db.models.oauth import UserToken +from ...db.models.oauth import OAuthToken from ...security.deps import get_current_user from ..oauth_base import OAuthProvider @@ -51,11 +51,11 @@ class Config(BaseModel): ) -async def _get_token(user_id: int, db: AsyncSession) -> UserToken: +async def _get_token(user_id: int, db: AsyncSession) -> OAuthToken: token = await db.scalar( - select(UserToken).where( - UserToken.user_id == user_id, - UserToken.service == provider.cfg.service, + select(OAuthToken).where( + OAuthToken.owner_id == user_id, + OAuthToken.service == provider.cfg.service, ) ) if not token: diff --git a/back/app/routes/gmail/gmail.py b/back/app/routes/gmail/gmail.py index c66a2d9..15ef556 100644 --- a/back/app/routes/gmail/gmail.py +++ b/back/app/routes/gmail/gmail.py @@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from ...db import get_session -from ...db.models.oauth import UserToken +from ...db.models.oauth import OAuthToken from ...security.deps import get_current_user from ..oauth_base import OAuthProvider @@ -91,10 +91,10 @@ def _b64url_decode(data: str) -> bytes: async def _get_gmail_token( user_id: int, db: AsyncSession -) -> Optional[UserToken]: +) -> Optional[OAuthToken]: return await db.scalar( - select(UserToken).where( - UserToken.user_id == user_id, UserToken.service == "gmail" + select(OAuthToken).where( + OAuthToken.owner_id == user_id, OAuthToken.service == "gmail" ) ) diff --git a/back/app/routes/oauth_base.py b/back/app/routes/oauth_base.py index 5a4621e..32b5639 100644 --- a/back/app/routes/oauth_base.py +++ b/back/app/routes/oauth_base.py @@ -16,7 +16,7 @@ from ..config import get_package_config from ..db.crud import users -from ..db.models.oauth import UserToken +from ..db.models.oauth import OAuthToken from ..db.models.user import User from ..security.jwt import decode_access_token @@ -171,7 +171,7 @@ async def auth(self, code: str, state: str, db: AsyncSession): tokens = resp.json() - token = UserToken( + token = OAuthToken( user_id=user.id, service=self.cfg.service, access_token=tokens.get("access_token"), @@ -207,9 +207,9 @@ async def auth(self, code: str, state: str, db: AsyncSession): async def refresh(self, user: User, db: AsyncSession): token = await db.scalar( - select(UserToken).where( - UserToken.user_id == user.id, - UserToken.service == self.cfg.service, + select(OAuthToken).where( + OAuthToken.owner_id == user.id, + OAuthToken.service == self.cfg.service, ) ) if not token: @@ -258,9 +258,9 @@ async def refresh(self, user: User, db: AsyncSession): async def me(self, user: User, db: AsyncSession): token = await db.scalar( - select(UserToken).where( - UserToken.user_id == user.id, - UserToken.service == self.cfg.service, + select(OAuthToken).where( + OAuthToken.owner_id == user.id, + OAuthToken.service == self.cfg.service, ) ) if not token: diff --git a/back/app/service.py b/back/app/service.py new file mode 100644 index 0000000..dc93680 --- /dev/null +++ b/back/app/service.py @@ -0,0 +1,46 @@ +from typing import Any, Callable + + +class Service: + def __init__(self, name: str, description: str): + self.name = name + self.description = description + self.actions: list[dict[str, Any]] = [] + self.reactions: list[dict[str, Any]] = [] + + def action(self, name: str, description: str): + """Decorator to register an action with metadata.""" + def wrapper(func: Callable): + self.actions.append({ + "name": name, + "description": description, + "function": func, + }) + return func + return wrapper + + def reaction(self, name: str, description: str): + """Decorator to register a reaction with metadata.""" + def wrapper(func: Callable): + self.reactions.append({ + "name": name, + "description": description, + "function": func, + }) + return func + return wrapper + + def to_dict(self) -> dict[str, Any]: + """Serialize service info for JSON output (excluding function refs).""" + return { + "name": self.name, + "description": self.description, + "actions": [ + {"name": a["name"], "description": a["description"]} + for a in self.actions + ], + "reactions": [ + {"name": r["name"], "description": r["description"]} + for r in self.reactions + ], + } diff --git a/docker-compose.yml b/docker-compose.yml index b5edab1..4abe949 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,38 +1,45 @@ services: + client_mobile: + build: + context: front + dockerfile: ./android/Dockerfile + volumes: + - shared-artifacts:/shared + # Optional: wait for a short while on first run to produce the APK + healthcheck: + test: ["CMD", "test", "-f", "/shared/client.apk"] + interval: 10s + timeout: 5s + retries: 30 + server: build: context: . dockerfile: back/Dockerfile + networks: + - backToFront ports: - "8080:8080" working_dir: /app - volumes: - - ./back/config.toml:/app/config.toml:ro - - # client_mobile: - # build: - # context: . - # dockerfile: front/android/Dockerfile - # volumes: - # - shared-artifacts:/shared - # # Optional: wait for a short while on first run to produce the APK - # healthcheck: - # test: ["CMD", "test", "-f", "/shared/client.apk"] - # interval: 10s - # timeout: 5s - # retries: 30 client_web: build: - context: . - dockerfile: front/Dockerfile + context: front + dockerfile: ./Dockerfile depends_on: - server: condition: service_started + client_mobile: + condition: service_completed_successfully + networks: + - backToFront ports: - - "8081:8081" + - "8081:80" volumes: - - ./shared-artifacts:/app/share/www + - shared-artifacts:/shared/:ro +volumes: + shared-artifacts: +networks: + backToFront: diff --git a/examples/example_config b/examples/example_config new file mode 100644 index 0000000..c279c1e --- /dev/null +++ b/examples/example_config @@ -0,0 +1,54 @@ +[security] +jwt_secret = "" + +[db] +uri="sqlite+aiosqlite:///app.db" + + +[routes.discord] +client_id = "" +client_secret = "" + +[routes.spotify] +# scope = "user-read-email user-read-currently-playing user-follow-read" +client_id = "" +client_secret = "" + +[routes.gmail] +# Provide your Google OAuth 2.0 Web client credentials +# Ensure the redirect URI http://127.0.0.1:8080/gmail/auth is added in Google Cloud Console +client_id = "" +client_secret = "" +# Optional: override scope or redirect URI here if needed +# scope = "https://www.googleapis.com/auth/gmail.readonly openid email profile" +# redirect_uri = "http://127.0.0.1:8080/gmail/auth" + +[routes.caldav] +# Uses Google OAuth credentials (you can reuse the same client as Gmail/YouTube) +# Add redirect URI: http://127.0.0.1:8080/caldav/auth +client_id = "" +client_secret = "" +# Optional overrides +# scope = "https://www.googleapis.com/auth/calendar.readonly openid email profile" +# redirect_uri = "http://127.0.0.1:8080/caldav/auth" + +[routes.youtube] +# Create OAuth credentials at https://console.cloud.google.com/apis/credentials +# Add http://127.0.0.1:8080/youtube/auth as an authorized redirect URI +client_id = "-." +client_secret = "" +# Optional overrides +# scope = "https://www.googleapis.com/auth/youtube.readonly openid email profile" +# redirect_uri = "http://127.0.0.1:8080/youtube/auth" + +[routes.reddit] +# Create a Reddit app at https://www.reddit.com/prefs/apps +# For local dev, use type: installed app (or web app) and add redirect URI: +# http://127.0.0.1:8080/reddit/auth +# Note: Installed apps have no secret; keep client_secret empty. Web apps have a secret. +client_id = "" +client_secret = "" +# Optional overrides: +# scope = "identity read" +# redirect_uri = "http://127.0.0.1:8080/reddit/auth" +# resource_headers.User-Agent should be descriptive; override via code if needed \ No newline at end of file diff --git a/front/android/gradle.properties b/examples/example_gradle similarity index 89% rename from front/android/gradle.properties rename to examples/example_gradle index 2e87c52..5b9f34c 100644 --- a/front/android/gradle.properties +++ b/examples/example_gradle @@ -20,3 +20,7 @@ org.gradle.jvmargs=-Xmx1536m # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true +RELEASE_STORE_FILE=/build/apk_key.jks +RELEASE_STORE_PASSWORD=xxxxxx +RELEASE_KEY_ALIAS=alias +RELEASE_KEY_PASSWORD=xxxxxx \ No newline at end of file diff --git a/front/.gitignore b/front/.gitignore index 65b27d6..f716dd0 100644 --- a/front/.gitignore +++ b/front/.gitignore @@ -6,3 +6,4 @@ node_modules dist dist-ssr *.local +*.jks diff --git a/front/Dockerfile b/front/Dockerfile index 0a8e887..ebcf49e 100644 --- a/front/Dockerfile +++ b/front/Dockerfile @@ -1,27 +1,33 @@ -# Nix builder -FROM nixos/nix:latest AS builder +FROM node:22-bookworm-slim AS build -# Copy our source and setup our working dir. -COPY .. /tmp/build -WORKDIR /tmp/build +ENV DEBIAN_FRONTEND=noninteractive -# Build our Nix environment (front-web helper) -RUN nix \ - --extra-experimental-features "nix-command flakes" \ - --option filter-syscalls false \ - build .#front +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + curl \ + ca-certificates && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* -# Copy the Nix store closure into a directory. -RUN mkdir /tmp/nix-store-closure -RUN cp -R $(nix-store -qR result/) /tmp/nix-store-closure +RUN corepack enable && corepack prepare pnpm@latest --activate -# Final image is based on scratch. -FROM scratch +RUN npm install -g serve -# Copy /nix/store and the built result -COPY --from=builder /tmp/nix-store-closure /nix/store -COPY --from=builder /tmp/build/result /app +WORKDIR /app -# Expose client_web port and run -EXPOSE 8081 -CMD ["/app/bin/web"] +COPY package.json pnpm-lock.yaml ./ + +ENV CI=true +RUN pnpm install --frozen-lockfile + +COPY . . + +RUN pnpm build:web + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose to 80 and remap it in compose since nginx defaults to 80 without parameters possible +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/front/android/.gitignore b/front/android/.gitignore index 4bf83a8..86c68a7 100644 --- a/front/android/.gitignore +++ b/front/android/.gitignore @@ -12,6 +12,7 @@ out/ .gradle/ build/ local.properties +gradle.properties *.log diff --git a/front/android/Dockerfile b/front/android/Dockerfile new file mode 100644 index 0000000..b2545b1 --- /dev/null +++ b/front/android/Dockerfile @@ -0,0 +1,58 @@ +FROM node:20-bookworm-slim + +# --- System Dependencies --- +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + bash curl unzip git ca-certificates && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +# --- Install OpenJDK 21 (Temurin) --- +RUN curl -L https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.9+10/OpenJDK21U-jdk_x64_linux_hotspot_21.0.9_10.tar.gz \ + -o /tmp/jdk21.tar.gz && \ + mkdir -p /usr/lib/jvm && \ + tar -xzf /tmp/jdk21.tar.gz -C /usr/lib/jvm && \ + rm /tmp/jdk21.tar.gz && \ + ln -s /usr/lib/jvm/jdk-21*/bin/java /usr/bin/java && \ + ln -s /usr/lib/jvm/jdk-21*/bin/javac /usr/bin/javac + +ENV JAVA_HOME=/usr/lib/jvm/jdk-21.0.9+10/ +ENV PATH="$JAVA_HOME/bin:$PATH" + + +# --- Install Android SDK --- +ENV ANDROID_SDK_ROOT="/sdk" \ + ANDROID_HOME="/sdk" \ + PATH="$PATH:/sdk/cmdline-tools/bin:/sdk/platform-tools" + +RUN mkdir /sdk && \ + curl -s https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip -o /cmdline-tools.zip && \ + unzip /cmdline-tools.zip -d /sdk/ && \ + rm /cmdline-tools.zip && \ + mkdir -p /sdk/cmdline-tools/latest && \ + mv /sdk/cmdline-tools/* /sdk/cmdline-tools/latest/ || true && \ + yes | /sdk/cmdline-tools/latest/bin/sdkmanager --sdk_root=/sdk --install \ + "platform-tools" "platforms;android-35" "build-tools;35.0.0" "cmdline-tools;latest" + +# --- Install Gradle --- +RUN curl -sL https://services.gradle.org/distributions/gradle-8.10-bin.zip -o gradle.zip && \ + mkdir /opt/gradle && \ + unzip gradle.zip -d /opt/gradle && \ + rm gradle.zip +ENV PATH="/opt/gradle/gradle-8.10/bin:$PATH" + +# --- Install pnpm --- +RUN npm install -g pnpm + +# --- Web Build --- +WORKDIR /build +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm run build:web +RUN npx cap sync android + +# --- Prepare Android Build --- +RUN chmod +x /build/entrypoint.sh + +CMD ["bash", "entrypoint.sh"] diff --git a/front/android/app/build.gradle b/front/android/app/build.gradle index 55c89ad..c66e170 100644 --- a/front/android/app/build.gradle +++ b/front/android/app/build.gradle @@ -3,6 +3,7 @@ apply plugin: 'com.android.application' android { namespace "io.github.sigmapitech" compileSdk rootProject.ext.compileSdkVersion + defaultConfig { applicationId "io.github.sigmapitech" minSdkVersion rootProject.ext.minSdkVersion @@ -13,15 +14,26 @@ android { ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } } + + signingConfigs { + release { + storeFile file(RELEASE_STORE_FILE) + storePassword RELEASE_STORE_PASSWORD + keyAlias RELEASE_KEY_ALIAS + keyPassword RELEASE_KEY_PASSWORD + } + } + buildTypes { release { minifyEnabled false + signingConfig signingConfigs.release } } } repositories { - flatDir{ + flatDir { dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' } } diff --git a/front/entrypoint.sh b/front/entrypoint.sh new file mode 100644 index 0000000..1043c49 --- /dev/null +++ b/front/entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Output dir +OUTPUT_DIR=/shared +APK_NAME=client.apk + +# Ensure output folder exist +mkdir -p $OUTPUT_DIR +npx cap sync android +cd android +# Clean previous build +./gradlew clean + +# Sync dependencies +./gradlew build --no-daemon --refresh-dependencies + +# Build project for release +./gradlew assembleRelease + +# Copy apk to output dir +cp /build/android/app/build/outputs/apk/release/app-release.apk $OUTPUT_DIR/$APK_NAME + +# Ensure build success +if [ -f "$OUTPUT_DIR/$APK_NAME" ]; then + echo "APK successfully generated : $OUTPUT_DIR/$APK_NAME" +else + echo "APK generation failed." + exit 1 +fi diff --git a/front/nginx.conf b/front/nginx.conf new file mode 100644 index 0000000..00b072f --- /dev/null +++ b/front/nginx.conf @@ -0,0 +1,24 @@ +server { + listen 80; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri /index.html =404; + } + + location /api/ { + proxy_pass http://server:8080/; + proxy_set_header Host $host; + } + + location /client.apk { + alias /shared/client.apk; + } + + location /shared/ { + alias /shared/; + autoindex on; + } +} diff --git a/front/src/api_url.ts b/front/src/api_url.ts index 51e40d0..95b0033 100644 --- a/front/src/api_url.ts +++ b/front/src/api_url.ts @@ -1 +1,3 @@ -export const API_BASE_URL = "http://127.0.0.1:8080"; +export const API_BASE_URL = import.meta.env.PROD + ? "http://localhost:8081/api" + : "http://localhost:8080"; diff --git a/front/src/components/form/submit-button/style.scss b/front/src/components/form/submit-button/style.scss index 7e400e5..b852433 100644 --- a/front/src/components/form/submit-button/style.scss +++ b/front/src/components/form/submit-button/style.scss @@ -1,18 +1,3 @@ -.btn { - display: flex; - color: black; - background: #96bfff; - border: 2px solid transparent; - padding: .5rem; - color: black; - - &:hover, &:focus-visible { - color: black; - background: #E4BEF8; - outline: none; - } - - &-validate { - width: 100%; - } +.btn-validate { + width: 100%; } diff --git a/front/src/index.scss b/front/src/index.scss index 8559337..04eb72f 100644 --- a/front/src/index.scss +++ b/front/src/index.scss @@ -35,11 +35,20 @@ h1 { } .btn { - border: 1px solid transparent; - padding: 0.6em 1.2em; font-size: 1em; font-weight: 500; font-family: inherit; cursor: pointer; transition: border-color 0.25s; + color: black; + background: #96bfff; + border: 2px solid transparent; + padding: .5rem 1rem; + color: black; + + &:hover, &:focus-visible { + color: black; + background: #E4BEF8; + outline: none; + } } diff --git a/front/src/routes/login/auth.scss b/front/src/routes/login/auth.scss index 7fad7ce..9f55358 100644 --- a/front/src/routes/login/auth.scss +++ b/front/src/routes/login/auth.scss @@ -101,20 +101,6 @@ margin: 0; font-size: 1rem; } - .btn-register { - display: flex; - align-items: center; - justify-content: center; - color: #96bfff; - border: 1px solid #96bfff; - padding: .5em 1em; - border-radius: 1em; - - &:hover { - background: #96bfff; - color: black; - } - } } .actions { diff --git a/front/src/routes/login/index.tsx b/front/src/routes/login/index.tsx index a23e584..f86c90e 100644 --- a/front/src/routes/login/index.tsx +++ b/front/src/routes/login/index.tsx @@ -57,7 +57,7 @@ export default function LoginPage() { const { email, password } = formData; handleFormSubmit({ - url: `${API_BASE_URL}/auth/login/`, + url: `${API_BASE_URL}/auth/login`, body: { email, password }, onSuccess: (data) => { login(data.token); diff --git a/front/src/routes/profile/style.scss b/front/src/routes/profile/style.scss index 2d3a162..71fbe94 100644 --- a/front/src/routes/profile/style.scss +++ b/front/src/routes/profile/style.scss @@ -28,7 +28,6 @@ flex: 1; padding: 0.6rem 0.8rem; border: 1px solid #585b70; - border-radius: 6px; font-size: 1rem; transition: border-color 0.2s; diff --git a/front/src/routes/register/auth.scss b/front/src/routes/register/auth.scss index 732b5e9..9f55358 100644 --- a/front/src/routes/register/auth.scss +++ b/front/src/routes/register/auth.scss @@ -101,20 +101,6 @@ margin: 0; font-size: 1rem; } - .btn-login { - display: flex; - align-items: center; - justify-content: center; - color: #96bfff; - border: 1px solid #96bfff; - padding: .5em 1em; - border-radius: 1em; - - &:hover { - background: #96bfff; - color: black; - } - } } .actions { diff --git a/front/src/routes/register/index.tsx b/front/src/routes/register/index.tsx index da79af6..321b53d 100644 --- a/front/src/routes/register/index.tsx +++ b/front/src/routes/register/index.tsx @@ -94,7 +94,7 @@ export default function RegisterPage() { } handleFormSubmit({ - url: `${API_BASE_URL}/auth/register/`, + url: `${API_BASE_URL}/auth/register`, body: { email, name, password }, onSuccess: (data) => { login(data.token); diff --git a/front/src/routes/workflow-create/style.scss b/front/src/routes/workflow-create/style.scss index 5102466..4867362 100644 --- a/front/src/routes/workflow-create/style.scss +++ b/front/src/routes/workflow-create/style.scss @@ -33,7 +33,6 @@ flex: 1; padding: 0.6rem 0.8rem; border: 1px solid #585b70; - border-radius: 6px; font-size: 1rem; transition: border-color 0.2s; @@ -56,7 +55,6 @@ align-items: flex-start; padding: 1rem 1.2rem; border: 1px solid #7f849c; - border-radius: 8px; margin-bottom: 1rem; background-color: #1e1e2e; transition: box-shadow 0.2s ease; diff --git a/front/src/routes/workflow/index.tsx b/front/src/routes/workflow/index.tsx index e5a0f23..42df131 100644 --- a/front/src/routes/workflow/index.tsx +++ b/front/src/routes/workflow/index.tsx @@ -10,26 +10,12 @@ import { useNodesState, } from "@xyflow/react"; import { useCallback, useEffect, useState } from "react"; -import { useParams } from "react-router"; +import { useNavigate, useParams } from "react-router"; import "@xyflow/react/dist/style.css"; import { API_BASE_URL } from "@/api_url"; import { useAuth } from "@/auth"; - import "./style.scss"; -interface WorkflowNode { - id: number; - node_id: number | null; - key: string; - value?: string; -} - -interface WorkflowDetail { - id: number; - workflow_id: number; - config: WorkflowNode[]; -} - const nodeDefaults = { sourcePosition: Position.Right, targetPosition: Position.Left, @@ -37,104 +23,99 @@ const nodeDefaults = { export default function GraphPage() { const { token } = useAuth(); + const navigate = useNavigate(); const { workflowId } = useParams<{ workflowId: string }>(); - const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); - - const [_indivNode, _setNode] = useState({ - id: 0, - node_id: null, - key: "", - value: "", - }); - const [loading, setLoading] = useState(true); const onConnect = useCallback( async (params: Connection) => { setEdges((els) => addEdge(params, els)); - if (!workflowId || !token) return; try { - const targetNodeId = params.target; - const sourceNodeId = params.source; - const res = await fetch( - `${API_BASE_URL}/workflow/${workflowId}/${targetNodeId}`, + `${API_BASE_URL}/workflow/${workflowId}/edges`, { - method: "PATCH", + method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ - parent_id: sourceNodeId, + from_node_id: params.source, + to_node_id: params.target, }), } ); if (!res.ok) { - console.error("Failed to update node parent:", res.statusText); + console.error("Failed to create edge:", res.statusText); return; } - const updatedNode = await res.json(); - setNodes((nds) => - nds.map((n) => - n.id === updatedNode.id.toString() - ? { - ...n, - data: { - label: `${updatedNode.key} (${updatedNode.id})`, - }, - } - : n - ) - ); + const savedEdge = await res.json(); + setEdges((eds) => [ + ...eds, + { + id: `e${savedEdge.from_node_id}-${savedEdge.to_node_id}`, + source: savedEdge.from_node_id.toString(), + target: savedEdge.to_node_id.toString(), + animated: true, + }, + ]); } catch (err) { - console.error("Error updating node parent:", err); + console.error("Error creating edge:", err); } }, - [setEdges, setNodes, workflowId, token] + [workflowId, token, setEdges] ); useEffect(() => { - const fetchNodes = async () => { + const fetchWorkflow = async () => { + if (!workflowId || !token) return; + setLoading(true); + try { - setLoading(true); - const res = await fetch(`${API_BASE_URL}/workflow/${workflowId}`, { + const resNodes = await fetch(`${API_BASE_URL}/workflow/${workflowId}`, { headers: { Authorization: `Bearer ${token}` }, }); + const dataNodes = await resNodes.json(); - if (!res.ok) { - console.error("Failed to fetch workflow:", res.statusText); + if ( + resNodes.status === 404 && + dataNodes.detail === "Workflow not found" + ) { + navigate("/workflow", { replace: true }); return; } - const data: WorkflowDetail = await res.json(); - - const fetchedNodes = data.config.map((node, index) => ({ + const fetchedNodes = dataNodes.nodes.map((node, index: number) => ({ id: node.id.toString(), - data: { label: `${node.key} (${node.id})` }, + data: { label: `Node ${node.id}` }, position: { - x: (node.node_id ?? 0) * 200, + x: (node.config?.parent_id ?? 0) * 200, y: index * 120, }, ...nodeDefaults, })); + setNodes(fetchedNodes); - const fetchedEdges = data.config - .filter((n) => n.node_id) - .map((n) => ({ - id: `e${n.node_id}-${n.id}`, - source: n.node_id?.toString(), - target: n.id.toString(), - animated: true, - })); + const resEdges = await fetch( + `${API_BASE_URL}/workflow/${workflowId}/edges`, + { + headers: { Authorization: `Bearer ${token}` }, + } + ); + const dataEdges = await resEdges.json(); - setNodes(fetchedNodes); + const fetchedEdges = dataEdges.map((edge) => ({ + id: `e${edge.from_node_id}-${edge.to_node_id}`, + source: edge.from_node_id.toString(), + target: edge.to_node_id.toString(), + animated: true, + })); setEdges(fetchedEdges); } catch (err) { console.error("Error loading workflow:", err); @@ -143,16 +124,15 @@ export default function GraphPage() { } }; - fetchNodes(); - }, [workflowId, token, setNodes, setEdges]); + fetchWorkflow(); + }, [workflowId, token, setNodes, setEdges, navigate]); const handleAddNode = useCallback(async () => { if (!workflowId || !token) return; - - const newId = nodes.length + 1; + const newNodeId = nodes.length + 1; const newNode = { - id: newId, - data: { label: `New Node (${newId})` }, + id: newNodeId, + data: { label: `Node ${newNodeId}` }, position: { x: Math.random() * 400, y: Math.random() * 400 }, ...nodeDefaults, }; @@ -164,12 +144,7 @@ export default function GraphPage() { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ - id: newNode.id, - node_id: null, - key: "send", - value: {}, - }), + body: JSON.stringify({ workflow_id: workflowId }), }); if (!res.ok) { @@ -178,13 +153,12 @@ export default function GraphPage() { } const savedNode = await res.json(); - setNodes((nds) => [ ...nds, { ...newNode, id: savedNode.id.toString(), - data: { label: `${savedNode.key} (${savedNode.id})` }, + data: { label: `Node ${savedNode.id}` }, }, ]); } catch (err) { diff --git a/front/vite-env.d.ts b/front/vite-env.d.ts index c934b98..c555498 100644 --- a/front/vite-env.d.ts +++ b/front/vite-env.d.ts @@ -2,4 +2,12 @@ /// +interface ImportMetaEnv { + readonly PROD: boolean; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + declare const __APP_PLATFORM__: "mobile" | "web";