Prechádzať zdrojové kódy

Enhanced overlay manager

Nicole Portas 1 mesiac pred
rodič
commit
209668bc5e

+ 4 - 1
docker-compose.yml

@@ -45,8 +45,11 @@ services:
   overlay-manager:
     build: ./overlay_manager
     restart: always
+    pid: "service:builder"
+    environment:
+      CBS_BUILD_TIMEOUT_SEC: ${CBS_BUILD_TIMEOUT_SEC:-900} # Matches your builder timeout
     volumes:
       - ./custom_overlays:/srv:rw
-      - ./overlay_db:/app/db   # <-- We mount a whole folder here now
+      - ./overlay_db:/app/db
     ports:
       - "0.0.0.0:11081:80"

+ 0 - 1
overlay_manager/Dockerfile

@@ -6,6 +6,5 @@ WORKDIR /app
 RUN pip install --no-cache-dir fastapi uvicorn jinja2 python-multipart
 
 COPY . .
-
 # Run the app on port 80
 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"]

+ 167 - 27
overlay_manager/main.py

@@ -3,6 +3,7 @@ import sqlite3
 import shutil
 import tempfile
 import logging
+import hashlib
 from urllib.parse import quote
 from fastapi import FastAPI, Request, File, UploadFile, Form, HTTPException
 from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse
@@ -47,7 +48,7 @@ def init_db():
             )
         """)
         
-        # Auto-migrate: Add description and is_readonly columns if they don't exist
+        # Auto-migrate: Add description, is_readonly, and password columns if they don't exist
         cursor = conn.cursor()
         cursor.execute("PRAGMA table_info(profiles)")
         columns = [info[1] for info in cursor.fetchall()]
@@ -55,6 +56,8 @@ def init_db():
             cursor.execute("ALTER TABLE profiles ADD COLUMN description TEXT DEFAULT ''")
         if "is_readonly" not in columns:
             cursor.execute("ALTER TABLE profiles ADD COLUMN is_readonly BOOLEAN NOT NULL DEFAULT 0")
+        if "password" not in columns:
+            cursor.execute("ALTER TABLE profiles ADD COLUMN password TEXT DEFAULT NULL")
         
         # Create default profiles if the DB is empty
         cursor.execute("SELECT count(*) FROM profiles")
@@ -74,18 +77,75 @@ def get_db_connection():
     conn.row_factory = sqlite3.Row
     return conn
 
+def hash_password(password: str) -> str:
+    return hashlib.sha256(password.encode('utf-8')).hexdigest()
+
+def is_compiling() -> bool:
+    """
+    Dynamically checks if a build is happening by scanning active processes.
+    Includes a timeout check to prevent infinite lockups if the builder hangs.
+    """
+    try:
+        # Match the timeout set in docker-compose (default 900 seconds / 15 mins)
+        timeout_sec = int(os.environ.get("CBS_BUILD_TIMEOUT_SEC", 900))
+        
+        # Get the overall system uptime to calculate process age
+        with open('/proc/uptime', 'r') as f:
+            sys_uptime = float(f.read().split()[0])
+            
+        ticks_per_sec = os.sysconf(os.sysconf_names['SC_CLK_TCK'])
+
+        for pid in os.listdir('/proc'):
+            if pid.isdigit():
+                try:
+                    with open(f'/proc/{pid}/cmdline', 'r') as f:
+                        cmdline = f.read().replace('\x00', ' ')
+                        
+                    # 'waf' is the build system for ArduPilot
+                    if 'waf' in cmdline:
+                        # Read the process stats to find out when it started
+                        with open(f'/proc/{pid}/stat', 'r') as f:
+                            stat_fields = f.read().split()
+                            # Field 22 (index 21) is starttime in clock ticks
+                            starttime_ticks = int(stat_fields[21])
+                            starttime_sec = starttime_ticks / ticks_per_sec
+                            
+                        process_uptime = sys_uptime - starttime_sec
+                        
+                        # If the waf process has been running longer than the timeout, it's hung
+                        if process_uptime > timeout_sec:
+                            logger.warning(f"Ignoring stale 'waf' process (PID {pid}): running for {process_uptime:.1f}s (exceeds {timeout_sec}s timeout)")
+                            continue
+                            
+                        return True
+                except (IOError, FileNotFoundError, IndexError, ValueError):
+                    continue
+    except Exception as e:
+        logger.debug(f"Process scan failed/unavailable: {e}")
+        
+    return False
+
 @app.get("/", response_class=HTMLResponse)
 async def index(request: Request, success_msg: str = None, error_msg: str = None):
     conn = get_db_connection()
     
-    profiles = conn.execute("SELECT * FROM profiles ORDER BY name").fetchall()
-    active_profile = conn.execute("SELECT * FROM profiles WHERE is_active = 1").fetchone()
+    profiles_raw = conn.execute("SELECT * FROM profiles ORDER BY name").fetchall()
+    profiles = []
+    for p in profiles_raw:
+        p_dict = dict(p)
+        p_dict['is_locked'] = bool(p_dict.get('password'))
+        profiles.append(p_dict)
+        
+    active_profile_raw = conn.execute("SELECT * FROM profiles WHERE is_active = 1").fetchone()
+    active_profile = dict(active_profile_raw) if active_profile_raw else None
+    if active_profile:
+        active_profile['is_locked'] = bool(active_profile.get('password'))
     
     files = []
     dirs = set()
     
     if active_profile:
-        files = conn.execute("SELECT * FROM files WHERE profile_id = ? ORDER BY filepath", (active_profile['id'],)).fetchall()
+        files = [dict(f) for f in conn.execute("SELECT * FROM files WHERE profile_id = ? ORDER BY filepath", (active_profile['id'],)).fetchall()]
         for f in files:
             dirname = os.path.dirname(f['filepath'])
             if dirname:
@@ -100,7 +160,8 @@ async def index(request: Request, success_msg: str = None, error_msg: str = None
         "files": files,
         "dirs": sorted(list(dirs)),
         "success_msg": success_msg,
-        "error_msg": error_msg
+        "error_msg": error_msg,
+        "is_compiling": is_compiling()
     })
 
 @app.post("/profile/create")
@@ -122,9 +183,12 @@ async def create_profile(name: str = Form(...)):
 async def rename_profile(profile_id: int = Form(...), new_name: str = Form(...)):
     try:
         with get_db_connection() as conn:
-            prof = conn.execute("SELECT is_readonly FROM profiles WHERE id = ?", (profile_id,)).fetchone()
-            if prof and prof['is_readonly']:
-                return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot rename a read-only profile.", status_code=303)
+            prof = conn.execute("SELECT is_readonly, password FROM profiles WHERE id = ?", (profile_id,)).fetchone()
+            if prof:
+                if prof['is_readonly']:
+                    return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot rename a read-only profile.", status_code=303)
+                if prof['password']:
+                    return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot rename a locked profile. Unlock it first.", status_code=303)
                 
             conn.execute("UPDATE profiles SET name = ? WHERE id = ?", (new_name.strip(), profile_id))
             conn.commit()
@@ -141,9 +205,12 @@ async def rename_profile(profile_id: int = Form(...), new_name: str = Form(...))
 async def annotate_profile(profile_id: int = Form(...), description: str = Form("")):
     try:
         with get_db_connection() as conn:
-            prof = conn.execute("SELECT is_readonly FROM profiles WHERE id = ?", (profile_id,)).fetchone()
-            if prof and prof['is_readonly']:
-                return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot edit notes on a read-only profile.", status_code=303)
+            prof = conn.execute("SELECT is_readonly, password FROM profiles WHERE id = ?", (profile_id,)).fetchone()
+            if prof:
+                if prof['is_readonly']:
+                    return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot edit notes on a read-only profile.", status_code=303)
+                if prof['password']:
+                    return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot edit notes on a locked profile. Unlock it first.", status_code=303)
 
             conn.execute("UPDATE profiles SET description = ? WHERE id = ?", (description, profile_id))
             conn.commit()
@@ -177,8 +244,8 @@ async def duplicate_profile(profile_id: int = Form(...)):
             
             cursor.execute("UPDATE profiles SET is_active = 0")
             
-            # Duplicates are NEVER read-only, even if cloned from a read-only profile
-            cursor.execute("INSERT INTO profiles (name, description, is_active, is_readonly) VALUES (?, ?, 1, 0)", (new_name, desc))
+            # Duplicates are NEVER read-only, and NEVER locked by default
+            cursor.execute("INSERT INTO profiles (name, description, is_active, is_readonly, password) VALUES (?, ?, 1, 0, NULL)", (new_name, desc))
             new_profile_id = cursor.lastrowid
             
             files = cursor.execute("SELECT filepath, content FROM files WHERE profile_id = ?", (profile_id,)).fetchall()
@@ -209,15 +276,18 @@ async def activate_profile(profile_id: int = Form(...)):
 async def delete_profile(profile_id: int = Form(...)):
     try:
         with get_db_connection() as conn:
-            prof = conn.execute("SELECT is_readonly, is_active FROM profiles WHERE id = ?").fetchone()
+            prof = conn.execute("SELECT is_readonly, is_active, password FROM profiles WHERE id = ?").fetchone()
             
-            if prof and prof['is_readonly']:
-                msg = quote("Cannot delete a read-only profile.")
-                return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
-                
-            if prof and prof['is_active']:
-                msg = quote("Cannot delete the currently active profile. Switch to another first.")
-                return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+            if prof:
+                if prof['is_readonly']:
+                    msg = quote("Cannot delete a read-only profile.")
+                    return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+                if prof['password']:
+                    msg = quote("Cannot delete a locked profile. Unlock it first.")
+                    return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+                if prof['is_active']:
+                    msg = quote("Cannot delete the currently active profile. Switch to another first.")
+                    return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
                 
             conn.execute("DELETE FROM profiles WHERE id = ?", (profile_id,))
             conn.commit()
@@ -227,8 +297,50 @@ async def delete_profile(profile_id: int = Form(...)):
         logger.error(f"Profile deletion failed: {e}")
         return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to delete profile.", status_code=303)
 
+@app.post("/profile/lock")
+async def lock_profile(profile_id: int = Form(...), password: str = Form(...)):
+    try:
+        if not password.strip():
+            return RedirectResponse(url=f"/patch-manager/?error_msg=Password cannot be empty.", status_code=303)
+            
+        hashed_pw = hash_password(password.strip())
+        with get_db_connection() as conn:
+            prof = conn.execute("SELECT is_readonly FROM profiles WHERE id = ?", (profile_id,)).fetchone()
+            if prof and prof['is_readonly']:
+                return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot lock a system read-only profile.", status_code=303)
+                
+            conn.execute("UPDATE profiles SET password = ? WHERE id = ?", (hashed_pw, profile_id))
+            conn.commit()
+        msg = quote("Profile locked successfully.")
+        return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
+    except Exception as e:
+        logger.error(f"Profile lock failed: {e}")
+        return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to lock profile.", status_code=303)
+
+@app.post("/profile/unlock")
+async def unlock_profile(profile_id: int = Form(...), password: str = Form(...)):
+    try:
+        hashed_pw = hash_password(password.strip())
+        with get_db_connection() as conn:
+            prof = conn.execute("SELECT password FROM profiles WHERE id = ?", (profile_id,)).fetchone()
+            if not prof or prof['password'] != hashed_pw:
+                return RedirectResponse(url=f"/patch-manager/?error_msg=Incorrect password.", status_code=303)
+                
+            conn.execute("UPDATE profiles SET password = NULL WHERE id = ?", (profile_id,))
+            conn.commit()
+        msg = quote("Profile unlocked successfully.")
+        return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
+    except Exception as e:
+        logger.error(f"Profile unlock failed: {e}")
+        return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to unlock profile.", status_code=303)
+
 @app.post("/deploy")
 async def deploy_active_profile():
+    # Double-check right before deploying to prevent race conditions
+    if is_compiling():
+        msg = quote("Deployment blocked: A build is currently in progress.")
+        return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+
     try:
         with get_db_connection() as conn:
             active = conn.execute("SELECT id, name FROM profiles WHERE is_active = 1").fetchone()
@@ -270,10 +382,14 @@ async def upload_file(
 ):
     try:
         with get_db_connection() as conn:
-            prof = conn.execute("SELECT is_readonly FROM profiles WHERE id = ?", (profile_id,)).fetchone()
-            if prof and prof['is_readonly']:
-                msg = quote("Cannot upload files to a read-only profile.")
-                return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+            prof = conn.execute("SELECT is_readonly, password FROM profiles WHERE id = ?", (profile_id,)).fetchone()
+            if prof:
+                if prof['is_readonly']:
+                    msg = quote("Cannot upload files to a read-only profile.")
+                    return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+                if prof['password']:
+                    msg = quote("Cannot upload files to a locked profile.")
+                    return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
 
             target_path = new_path.strip() if new_path.strip() else existing_path.strip()
             clean_path = target_path.strip("/")
@@ -302,6 +418,13 @@ async def upload_file(
 async def delete_file(file_id: int = Form(...)):
     try:
         with get_db_connection() as conn:
+            file_record = conn.execute("SELECT profile_id FROM files WHERE id = ?", (file_id,)).fetchone()
+            if file_record:
+                prof = conn.execute("SELECT is_readonly, password FROM profiles WHERE id = ?", (file_record['profile_id'],)).fetchone()
+                if prof and (prof['is_readonly'] or prof['password']):
+                    msg = quote("Cannot delete file from a locked or read-only profile.")
+                    return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+
             conn.execute("DELETE FROM files WHERE id = ?", (file_id,))
             conn.commit()
         return RedirectResponse(url=f"/patch-manager/", status_code=303)
@@ -314,8 +437,18 @@ async def edit_file(request: Request, file_id: int, error_msg: str = None):
     try:
         with get_db_connection() as conn:
             file_record = conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
-            active_profile = conn.execute("SELECT * FROM profiles WHERE is_active = 1").fetchone()
-            profiles = conn.execute("SELECT * FROM profiles ORDER BY name").fetchall()
+            
+            active_profile_raw = conn.execute("SELECT * FROM profiles WHERE is_active = 1").fetchone()
+            active_profile = dict(active_profile_raw) if active_profile_raw else None
+            if active_profile:
+                active_profile['is_locked'] = bool(active_profile.get('password'))
+                
+            profiles_raw = conn.execute("SELECT * FROM profiles ORDER BY name").fetchall()
+            profiles = []
+            for p in profiles_raw:
+                p_dict = dict(p)
+                p_dict['is_locked'] = bool(p_dict.get('password'))
+                profiles.append(p_dict)
             
         if not file_record:
             msg = quote("File not found in database.")
@@ -346,6 +479,13 @@ async def save_file(file_id: int = Form(...), content: str = Form(...)):
     try:
         content_bytes = content.encode('utf-8')
         with get_db_connection() as conn:
+            file_record = conn.execute("SELECT profile_id FROM files WHERE id = ?", (file_id,)).fetchone()
+            if file_record:
+                prof = conn.execute("SELECT is_readonly, password FROM profiles WHERE id = ?", (file_record['profile_id'],)).fetchone()
+                if prof and (prof['is_readonly'] or prof['password']):
+                    msg = quote("Cannot modify files in a locked or read-only profile.")
+                    return RedirectResponse(url=f"/patch-manager/edit?file_id={file_id}&error_msg={msg}", status_code=303)
+
             conn.execute("UPDATE files SET content = ? WHERE id = ?", (content_bytes, file_id))
             conn.commit()
             

+ 23 - 3
overlay_manager/templates/edit.html

@@ -76,6 +76,11 @@
                             <div class="list-group-item d-flex justify-content-between align-items-center profile-list-item {% if p.id == active_profile.id %}active{% endif %}">
                                 <div class="w-100 fw-bold text-muted {% if p.id == active_profile.id %}text-white{% endif %}">
                                     {{ p.name }}
+                                    {% if p.is_readonly %}
+                                        <i class="bi bi-shield-lock-fill ms-1" title="Read-Only System Profile"></i>
+                                    {% elif p.is_locked %}
+                                        <i class="bi bi-lock-fill ms-1" title="User Locked"></i>
+                                    {% endif %}
                                     {% if p.id == active_profile.id %}<i class="bi bi-check-circle-fill ms-2"></i>{% endif %}
                                 </div>
                             </div>
@@ -97,15 +102,23 @@
                         </div>
                         
                         <div class="card-body p-0">
+                            {% if active_profile.is_readonly or active_profile.is_locked %}
+                            <div class="alert alert-warning m-3 shadow-sm border-0">
+                                <i class="bi bi-lock-fill me-2"></i> This profile is locked or read-only. You can view the file but cannot save changes.
+                            </div>
+                            {% endif %}
+                            
                             <form action="edit" method="post" id="editForm">
                                 <input type="hidden" name="file_id" value="{{ file_id }}">
                                 
                                 <textarea id="code-editor" name="content">{{ content }}</textarea>
 
+                                {% if not active_profile.is_readonly and not active_profile.is_locked %}
                                 <div class="p-3 bg-light text-end rounded-bottom border-top">
                                     <a href="./" class="btn btn-secondary me-2">Cancel</a>
                                     <button type="submit" class="btn btn-success fw-bold"><i class="bi bi-save2-fill me-2"></i> Save Changes to DB</button>
                                 </div>
+                                {% endif %}
                             </form>
                         </div>
                     </div>
@@ -131,17 +144,24 @@
     <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/mode/python/python.min.js"></script>
     
     <script>
+        var isLocked = {{ 'true' if active_profile.is_readonly or active_profile.is_locked else 'false' }};
         var editor = CodeMirror.fromTextArea(document.getElementById("code-editor"), {
             lineNumbers: true,
             mode: "text/x-c++src", 
             theme: "monokai",
             matchBrackets: true,
             indentUnit: 4,
-            indentWithTabs: false
+            indentWithTabs: false,
+            readOnly: isLocked ? "nocursor" : false
         });
         
-        document.getElementById('editForm').addEventListener('submit', function() {
-            editor.save();
+        document.getElementById('editForm').addEventListener('submit', function(e) {
+            if (isLocked) {
+                e.preventDefault();
+                alert('Cannot save. The profile is currently locked.');
+            } else {
+                editor.save();
+            }
         });
     </script>
 </body>

+ 111 - 13
overlay_manager/templates/index.html

@@ -66,7 +66,7 @@
                 <div class="col-md-3">
                     <div class="card mb-4">
                         <div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
-                            <h6 class="mb-0 fw-bold text-dark"><i class="bi bi-diagram-3-fill me-2 text-primary"></i>Profiles / Loadouts</h6>
+                            <h6 class="mb-0 fw-bold text-dark"><i class="bi bi-diagram-3-fill me-2 text-primary"></i>Overlay Profiles</h6>
                         </div>
                         <div class="list-group list-group-flush">
                             {% for p in profiles %}
@@ -75,7 +75,11 @@
                                     <input type="hidden" name="profile_id" value="{{ p.id }}">
                                     <div onclick="this.parentNode.submit();" class="w-100 fw-bold">
                                         {{ p.name }}
-                                        {% if p.is_readonly %}<i class="bi bi-lock-fill ms-1" title="Read-Only"></i>{% endif %}
+                                        {% if p.is_readonly %}
+                                            <i class="bi bi-shield-lock-fill ms-1" title="Read-Only System Profile"></i>
+                                        {% elif p.is_locked %}
+                                            <i class="bi bi-lock-fill text-warning ms-1" title="User Locked"></i>
+                                        {% endif %}
                                         {% if p.is_active %}<i class="bi bi-check-circle-fill ms-2"></i>{% endif %}
                                     </div>
                                 </form>
@@ -85,20 +89,33 @@
                                         <i class="bi bi-three-dots-vertical fs-5"></i>
                                     </button>
                                     <ul class="dropdown-menu shadow-sm dropdown-menu-end">
-                                        {% if not p.is_readonly %}
-                                        <li>
-                                            <a class="dropdown-item" href="javascript:void(0)" onclick="openRenameModal({{ p.id }}, '{{ p.name|escape }}')">
-                                                <i class="bi bi-pencil-square me-2 text-primary"></i>Rename Profile
-                                            </a>
-                                        </li>
+                                        {% if p.is_locked %}
+                                            <li>
+                                                <a class="dropdown-item" href="javascript:void(0)" onclick="openUnlockModal({{ p.id }}, '{{ p.name|escape }}')">
+                                                    <i class="bi bi-unlock-fill me-2 text-warning"></i>Unlock Profile
+                                                </a>
+                                            </li>
+                                        {% elif not p.is_readonly %}
+                                            <li>
+                                                <a class="dropdown-item" href="javascript:void(0)" onclick="openRenameModal({{ p.id }}, '{{ p.name|escape }}')">
+                                                    <i class="bi bi-pencil-square me-2 text-primary"></i>Rename Profile
+                                                </a>
+                                            </li>
+                                            <li>
+                                                <a class="dropdown-item" href="javascript:void(0)" onclick="openLockModal({{ p.id }}, '{{ p.name|escape }}')">
+                                                    <i class="bi bi-lock-fill me-2 text-warning"></i>Lock Profile
+                                                </a>
+                                            </li>
                                         {% endif %}
+                                        
                                         <li>
                                             <form action="/patch-manager/profile/duplicate" method="post" class="m-0 p-0">
                                                 <input type="hidden" name="profile_id" value="{{ p.id }}">
                                                 <button type="submit" class="dropdown-item"><i class="bi bi-copy me-2 text-success"></i>Clone Profile</button>
                                             </form>
                                         </li>
-                                        {% if not p.is_active and not p.is_readonly %}
+                                        
+                                        {% if not p.is_active and not p.is_readonly and not p.is_locked %}
                                         <li><hr class="dropdown-divider"></li>
                                         <li>
                                             <form action="/patch-manager/profile/delete" method="post" class="m-0 p-0" onsubmit="return confirm('Delete this profile completely?');">
@@ -121,6 +138,17 @@
                         </div>
                     </div>
 
+                    {% if is_compiling %}
+                    <div class="card bg-danger text-white shadow border-0">
+                        <div class="card-body text-center p-4">
+                            <div class="spinner-border text-light mb-3" role="status" style="width: 3rem; height: 3rem;">
+                                <span class="visually-hidden">Loading...</span>
+                            </div>
+                            <h5 class="fw-bold">Build in Progress</h5>
+                            <p class="small text-white-75 mb-0">Another user is currently compiling. Deployment is locked to prevent overwriting files. Please wait.</p>
+                        </div>
+                    </div>
+                    {% else %}
                     <div class="card bg-dark text-white shadow">
                         <div class="card-body text-center p-4">
                             <i class="bi bi-rocket-takeoff-fill display-4 text-warning mb-3 d-block"></i>
@@ -133,6 +161,7 @@
                             </form>
                         </div>
                     </div>
+                    {% endif %}
                 </div>
 
                 <div class="col-md-9">
@@ -144,8 +173,8 @@
                         <div class="card-body p-3 bg-light">
                             <form action="/patch-manager/profile/annotate" method="post" class="d-flex flex-column gap-2">
                                 <input type="hidden" name="profile_id" value="{{ active_profile.id }}">
-                                <textarea class="form-control" name="description" rows="3" {% if active_profile.is_readonly %}disabled{% endif %} placeholder="Add notes about this loadout (e.g., 'Aggressive PID tune, experimental VTX settings')">{{ active_profile.description }}</textarea>
-                                {% if not active_profile.is_readonly %}
+                                <textarea class="form-control" name="description" rows="3" {% if active_profile.is_readonly or active_profile.is_locked %}disabled{% endif %} placeholder="Add notes about this loadout (e.g., 'Aggressive PID tune, experimental VTX settings')">{{ active_profile.description }}</textarea>
+                                {% if not active_profile.is_readonly and not active_profile.is_locked %}
                                 <div class="text-end">
                                     <button type="submit" class="btn btn-warning btn-sm fw-bold"><i class="bi bi-save me-1"></i> Save Notes</button>
                                 </div>
@@ -159,10 +188,14 @@
                             <h6 class="card-title mb-0 fw-bold text-dark"><i class="bi bi-cloud-arrow-up text-success me-2"></i>Upload File to Profile</h6>
                         </div>
                         
-                        {% if active_profile.is_readonly %}
+                        {% if active_profile.is_readonly or active_profile.is_locked %}
                         <div class="card-body p-4 bg-light text-center text-muted">
                             <i class="bi bi-lock-fill fs-3 d-block mb-2"></i>
-                            <p class="mb-0">This is a locked, unmodified base profile. You cannot upload files here.<br><strong>Clone this profile</strong> from the menu on the left to start making changes.</p>
+                            {% if active_profile.is_readonly %}
+                                <p class="mb-0">This is a locked, unmodified base profile. You cannot upload files here.<br><strong>Clone this profile</strong> from the menu on the left to start making changes.</p>
+                            {% else %}
+                                <p class="mb-0">This profile is locked by a user. You cannot upload files here.<br><strong>Unlock this profile</strong> from the menu on the left if you know the password, or <strong>Clone this profile</strong> to make your own copy and start making changes.</p>
+                            {% endif %}
                         </div>
                         {% else %}
                         <div class="card-body p-3 bg-light">
@@ -222,6 +255,8 @@
                                                 <a href="/patch-manager/download?file_id={{ file.id }}" class="btn btn-sm btn-outline-info me-2" title="Download">
                                                     <i class="bi bi-download"></i>
                                                 </a>
+                                                
+                                                {% if not active_profile.is_readonly and not active_profile.is_locked %}
                                                 <a href="/patch-manager/edit?file_id={{ file.id }}" class="btn btn-sm btn-outline-primary me-2">
                                                     <i class="bi bi-pencil me-1"></i> Edit
                                                 </a>
@@ -231,6 +266,7 @@
                                                         <i class="bi bi-trash"></i>
                                                     </button>
                                                 </form>
+                                                {% endif %}
                                             </td>
                                         </tr>
                                         {% else %}
@@ -276,6 +312,56 @@
         </div>
     </div>
 
+    <div class="modal fade" id="lockModal" tabindex="-1" aria-hidden="true">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <form action="/patch-manager/profile/lock" method="post">
+                    <div class="modal-header bg-light">
+                        <h5 class="modal-title fw-bold text-dark"><i class="bi bi-lock-fill me-2 text-warning"></i>Lock Profile</h5>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+                    </div>
+                    <div class="modal-body p-4">
+                        <input type="hidden" name="profile_id" id="lock-profile-id">
+                        <p>Locking <strong id="lock-profile-name"></strong> will prevent it from being edited or deleted until the password is provided.</p>
+                        <div class="mb-3">
+                            <label class="form-label text-muted small fw-bold">Set Password</label>
+                            <input type="password" class="form-control" name="password" required>
+                        </div>
+                    </div>
+                    <div class="modal-footer bg-light d-flex justify-content-between">
+                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
+                        <button type="submit" class="btn btn-warning fw-bold text-dark"><i class="bi bi-lock-fill me-1"></i>Lock Profile</button>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+
+    <div class="modal fade" id="unlockModal" tabindex="-1" aria-hidden="true">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <form action="/patch-manager/profile/unlock" method="post">
+                    <div class="modal-header bg-light">
+                        <h5 class="modal-title fw-bold text-dark"><i class="bi bi-unlock-fill me-2 text-warning"></i>Unlock Profile</h5>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+                    </div>
+                    <div class="modal-body p-4">
+                        <input type="hidden" name="profile_id" id="unlock-profile-id">
+                        <p>Enter the password to unlock <strong id="unlock-profile-name"></strong>.</p>
+                        <div class="mb-3">
+                            <label class="form-label text-muted small fw-bold">Password</label>
+                            <input type="password" class="form-control" name="password" required>
+                        </div>
+                    </div>
+                    <div class="modal-footer bg-light d-flex justify-content-between">
+                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
+                        <button type="submit" class="btn btn-warning fw-bold text-dark"><i class="bi bi-unlock-fill me-1"></i>Unlock Profile</button>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+
     <footer class="py-3 bg-dark text-white-50 mt-auto">
         <div class="container-fluid px-4 d-flex justify-content-between">
             <div>
@@ -297,6 +383,18 @@
             document.getElementById('rename-new-name').value = profileName;
             new bootstrap.Modal(document.getElementById('renameModal')).show();
         }
+
+        function openLockModal(profileId, profileName) {
+            document.getElementById('lock-profile-id').value = profileId;
+            document.getElementById('lock-profile-name').innerText = profileName;
+            new bootstrap.Modal(document.getElementById('lockModal')).show();
+        }
+
+        function openUnlockModal(profileId, profileName) {
+            document.getElementById('unlock-profile-id').value = profileId;
+            document.getElementById('unlock-profile-name').innerText = profileName;
+            new bootstrap.Modal(document.getElementById('unlockModal')).show();
+        }
     </script>
 </body>
 </html>