透過前兩篇測試, 我已經初步建立一個可動態載入函式模組, 只要透過 HTTP 請求即可執行該模組的 Python 函式執行平台. 被載入的函式模組必須具有 main() 函式 (這是主程式 serverless.py 中設定好的), 其格式如下 :
# func_module.py
def main(request):
result='do something'
return result
將函式模組放在 ~/flask_apps/serverless/functions 底下, 然後訪問下列格式的網址, Gunicorn 伺服器就會執行函式模組 func_module.py 並傳回結果 :
# https://flask.tony1966.cc/function/func_module
例如在前兩篇測試中, 我撰寫了一個 hello.py 模組來測試動態載入, 測試連結如下 :
但目前只是初步驗證了動態載入功能可行而已, 若撰寫了一個新的函式模組 (例如 LINE Bot 的 webhook), 只能透過 SSH 連線主機將函式模組檔案放到 ~/flask_apps/serverless/functions 下, 我希望能在此系統上開發一個線上編輯器, 讓被授權的使用者可以線上顯示, 新增, 編輯, 刪除, 與執行函式模組. 本篇先來實作編輯器的 UI 介面部分.
本系列全部的測試紀錄參考 :
1. 函式模組檔案列表功能 :
首先來寫一個顯示 ~/flask-apps/serverless/functions 下所有函式列表的模組, 可以呼叫 os.listdir() 取得副檔名為 .py 的檔案名稱串列, 然後濾掉 __init__.py 即可得到函式模組名稱串列, 再以迴圈遍歷此串列產生網頁表格的 HTML 字串傳回即可, 程式碼如下 :
# list_functions.py
import os
def main(request):
# 取得 functions 目錄絕對路徑
functions_dir='./functions'
# 取得所有 .py 檔案(但排除 __init__.py)
try:
files=os.listdir(functions_dir)
py_files=[f[:-3] for f in files if f.endswith('.py') and f != '__init__.py']
py_files.sort() # 按字母順序排序
except FileNotFoundError:
return '<p>directory ./functions not found!</p>'
# 產生 HTML 碼
html = '<h2>函式列表</h2>'
html += '<table border="1" cellspacing="0" cellpadding="6" style="border-collapse: collapse;">'
html += '<tr><th>函式名稱</th><th>執行</th><th>編輯</th><th>刪除</th></tr>'
for func in py_files:
html += f'<tr>'
html += f'<td>{func}</td>'
html += f'<td><a href="/function/{func}">執行</a></td>'
html += f'<td><a href="/function/edit_function?module_name={func}">編輯</a></td>'
html += f'<td><a href="/function/delete_function?module_name={func}">刪除</a></td>'
html += f'</tr>'
html += '</table>'
html += '<br><a href="/function/add_function">新增函式</a> '
html += '<a href="/logout">登出</a>'
return html
注意, 此處之登出網址因為牽涉權限控管機制, 必須與登入路徑一起在主程式 serverless.py 中處理, 不屬於 function 路徑管轄. 此表格有四欄, 第一欄是函式模組名稱, 其餘三欄分別為執行, 編輯, 與刪除該模組的預定超連結, 目前尚未實作, 將此 list_functions.py 放到 /functions 下, 訪問下列網址 :
結果如下 :
其中只有 "執行" 功能有作用, 其他尚待後續實作. 但這些管理功能不應該放公眾存取, 必須加上權限控管機制, 底下的 "登出" 超連結正是為了此目的而預先設置.
2. 建立管理用的密碼 (password) 與權杖 (token) :
後端線上管理功能必須對使用權限加以限制, 由於此函式執行平台系統只限我個人使用, 因此可採用類似數位簽章的 password + token 方式來做, password 是自訂的好記密碼 (登入時會在網路上傳遞); 而 token 則是程式產生的隨機權杖 (存放於伺服端, 不會在網路上傳遞), 登入時伺服端的 Flask 應用程式 serverless.py 會用它來加密與解密 session cookie 資料.
這個權限管控機制運作方式為, 當使用者輸入密碼傳送到伺服端比對, 若密碼正確, Flask 程式設定 session 資料時 (例如 session['authenticated']=True), 它會將此 session 資料序列化後用 token 加密為 session cookie 回傳給瀏覽器儲存. 後續當使用者訪問受保護頁面時, 瀏覽器自動送出此 cookie, Flask 用 token 解密並驗證 cookie 為真即解密成功, 其他使用者因不知加密用的 token 所以無法為偽造 cookie 來存取受保護的頁面.
首先來產生加密解密用的權杖, 這可以用 Python 內建的 secrets 模組來產生 :
>>> import secrets
>>> dir(secrets)
['DEFAULT_ENTROPY', 'SystemRandom', '__all__', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', '_sysrand', 'base64', 'binascii', 'choice', 'compare_digest', 'randbelow', 'randbits', 'token_bytes', 'token_hex', 'token_urlsafe']
其中 token_bytes(), token_hex(), 與 token_urlsafe() 函式都可以用來產生一組權杖, 注意, 其傳入參數 n 並不是回傳的權杖字元數, 摘要說明如下表 :
| 函式名稱 | 回傳長度 & 內容 | 適合的用途 |
|---|---|---|
| secrets.token_bytes(n) | n 個隨機位元組序列 (bytes 類型) | 產生純位元組的機密資料 |
| secrets.token_hex(n) | 2n 個十六進位隨機字元 (str 類型) | 產生可印出的 token |
| secrets.token_urlsafe(n) | 約 ceil(4n/3) 個隨機字元 (英數字, 底線等) | 適合用於網址內傳遞 |
例如 :
>>> secrets.token_bytes(8)
b'\x02t\xa9\x19\xfc\xb3k_'
>>> secrets.token_hex(16)
'ce12b3f0ed18cd24b82de12d396d0559' (傳回 32 個字元)
>>> secrets.token_urlsafe(32)
'xwumR2K9mLDr0-U010jGaC6Cf79rthtryMJhx5eU0bs' (傳回 40 個字元)
其中以 secrets.token_urlsafe() 傳回的隨機字元較合用.
接著在專案目錄 ~/flask_apps/serverless/ 下用 nano 編輯一個 .env 檔來儲存上面用 secrets.token_urlsafe(32) 產生的權杖 :
tony1966@LX2438:~/flask_apps/serverless$ sudo nano .env
[sudo] tony1966 的密碼:
輸入下面資訊設定權杖變數為 token (這是範例) :
SECRET_TOKEN=function_admin
SECRET_KEY=xwumR2K9mLDr0-U010jGaC6Cf79rthtryMJhx5eU0bs
按 Ctrl+O 儲存後按 Ctrl+X 跳出 nano 即可.
3. 安裝 python-dotenv 套件 :
關於 python-dotenv 套件用法參考之前的筆記 :
用 pip3 install python-dotenv 套件 :
tony1966@LX2438:~/flask_apps/serverless$ pip3 install python-dotenv
Defaulting to user installation because normal site-packages is not writeable
Collecting python-dotenv
Downloading python_dotenv-1.1.1-py3-none-any.whl (20 kB)
Installing collected packages: python-dotenv
Successfully installed python-dotenv-1.1.1
使用時要匯入 dotenv 模組, 此處使用 dotenv 的另類取值法, 透過 dotenv.dotenv_values() 載入 .env 檔, 它會傳回一個 config 字典, 直接用字典的 get() 方法取出 token, 這樣就不要用到 os 模組了 :
>>> from dotenv import dotenv_values
>>> config=dotenv_values('.env')
>>> SECRET_TOKEN=config.get('SECRET_TOKEN')
>>> print(SECRET_TOKEN)
function_admin (這是範例)
>>> SECRET_KEY=config.get('SECRET_KEY')
>>> print(SECRET_KEY)
xwumR2K9mLDr0-U010jGaC6Cf79rthtryMJhx5eU0bs (這是範例)
4. 修改 serverless.py 添加登入登出存取權限機制 :
上面的權限控管機制要添加至主程式 serverless.py 裡面, 程式碼修改如下 :
# serverless.py
from flask import Flask, request, jsonify, session
import importlib.util
import os
import logging
from dotenv import dotenv_values
def check_auth(): # 檢查使用者是否已登入
return session.get('authenticated') == True
app=Flask(__name__)
# 從 .env 讀取權杖 (密碼) 與金鑰
config=dotenv_values('.env')
SECRET_TOKEN=config.get('SECRET_TOKEN') # 易記的令牌 (類似密碼)
SECRET_KEY=config.get('SECRET_KEY') # 簽章加密用的金鑰
app.secret_key=SECRET_KEY # 用來簽章與驗證 session cookie
# 指定函式模組所在的資料夾
FUNCTIONS_DIR=os.path.expanduser('./functions')
# 指定錯誤日誌檔 (在目前工作目錄下)
logging.basicConfig(filename='serverless_error.log', level=logging.ERROR)
# 需要驗證的函式列表
PROTECTED_FUNCTIONS=['list_functions',
'add_function',
'save_function',
'edit_function',
'update_function',
'delete_function'
]
# 登入管理功能
@app.route('/login', methods=['GET', 'POST'])
def login():
# GET 請求 : 顯示登入頁面
if request.method == 'GET':
return '''
<!DOCTYPE html>
<html>
<head><title>系統登入</title></head>
<body>
<h2>系統登入</h2>
<form method="post">
<input type="password" name="token" placeholder="請輸入密碼" required>
<button type="submit">登入</button>
</form>
</body>
</html>
'''
# POST 請求 : 處理登入請求
if request.is_json: # JSON 登入
token=request.json.get('token')
else: # 表單登入
token=request.form.get('token')
if token == SECRET_TOKEN: # 驗證登入密碼
# 將登入狀態儲存在瀏覽器的 session cookie 中 (以明碼方式儲存)
# Flask 會用金鑰對資料進行簽章 (非加密) 確保內容未被竄改
session['authenticated']=True
# 回應登入成功
if request.is_json:
return jsonify({'message': '登入成功'})
else:
return '<p>登入成功!<a href="/function/list_functions">查看函式列表</a></p>'
else: # 密碼錯誤 : 回應登入失敗訊息
if request.is_json:
return jsonify({'message': '登入失敗'}), 401
else:
return '<p>登入失敗!<a href="/login">重新登入</a></p>', 401
# 登出管理功能
@app.route('/logout')
def logout():
session.clear() # 清除伺服端 Flask session 字典中的所有鍵值對
return '<p>已登出!<a href="/login">重新登入</a></p>'
# 動態載入 & 執行函式模組 (支援 RESTful)
@app.route('/function/<func_name>', defaults={'subpath': ''}, methods=['GET', 'POST'])
@app.route('/function/<func_name>/<path:subpath>', methods=['GET', 'POST'])
def handle_function(func_name, subpath): # 傳入 subpath 支援 RESTful
# 1. 檢查是否為保護的函式模組且使用者已登入
if func_name in PROTECTED_FUNCTIONS and not check_auth():
return jsonify({'error': 'Authentication required', 'login_url': '/login'}), 401
# 2. 取得檔案路徑
func_path=os.path.join(FUNCTIONS_DIR, f'{func_name}.py')
if not os.path.isfile(func_path): # 模組檔案不存在 -> 回 404
return jsonify({'error': f'Function "{func_name}" not found'}), 404
try:
# 3. 動態載入模組 (絕對路徑)
spec=importlib.util.spec_from_file_location(func_name, func_path)
module=importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# 4. 檢查模組中有無 main() 函式 :
if not hasattr(module, 'main'): # 模組中無 main() 函式
return jsonify({'error': f'Module "{func_name}" has no main()'}), 400
# 5. 將 subpath 加入 request 中 (支援 RESTful)
request.view_args['subpath']=subpath
# 6. 執行模組中的函式 :
result=module.main(request) # 將 request 傳給被載入模組由其自行處理參數
# 7. 傳回函式執行結果
return result
except Exception as e:
logging.exception(f'Error in function {func_name}\n{e}') # 紀錄錯誤於日誌
return jsonify({'error': 'Function execution failed'}), 500
if __name__ == '__main__':
app.run(debug=True)
由於大部分的函式是允許直接呼叫, 只有管理功能相關的函式需要擁有權限才能呼叫, 這裡使用一個串列來記錄需要被保護與管控存取權限的函式模組名稱, 每次呼叫 https://flask.tony1966.cc/function/<func_name> 進入 handle_function() 時都會先檢查這個函式模組是否為需要保護的對象, 是的話就檢查登入記錄 (session cookie 存在且符合), 若已登入過就會載入該模組並執行, 否則就回應 401 錯誤, 並提供登入網址.
此外 serverless.py 也添加了登入路由 (https://flask.tony1966.cc/login) 與登出路由 (https://flask.tony1966.cc/logout), 登入路由的處理函式是兩用的, 它接受 GET 與 POST 請求, 若為 GET 請求就回應一個密碼輸入欄位, 由於此表單沒有指定 action 欄位, 因此按下登入鈕時預設會提交給目前這個網頁 (即 https://flask.tony1966.cc/login), 但這時是以 POST 方法進來, 會呼叫 request.form.get() 取得表單中的 password 欄位, 並與環境變數中取出的 SECRET_PASSWORD 比對, 符合的話就會設定 session['authenticated']=True, Flask 會用環境變數中取出的 SECRET_TOKEN 將此 session 變數加密為 cookie 在回應登入成功訊息時傳回客戶端瀏覽器儲存, 後續訪問此網域時就會送出此 session cookie 讓 check_auth() 檢查, 符合才會被允許執行所請求的函式模組.
因為修改了 serverless.py, 所以必須重啟 serverless 系統服務 :
tony1966@LX2438:~/flask_apps/serverless$ sudo systemctl restart serverless
因為 list_functions.py 被保護了, 因此這時再次訪問 https://flask.tony1966.cc/function/list_functions 就會被拒絕了 :
必須先登入系統才行, 但訪問登入網址 https://flask.tony1966.cc/login 會出現 404 :
這是因為 serverless.py 中新增了 /login 與 /logout 這兩個路由, 但之前站台定義檔只為 /function 路由指定了轉發網址 http://127.0.0.1:8000, 應該再為 /login 與 /logout 指定相同網址, 但其實可以乾脆指定根目錄 / 轉發網址即可, 用 nano 編輯站台定義檔 flask.tony1966.cc :
tony1966@LX2438:~/flask_apps/serverless$ sudo nano /etc/nginx/sites-available/flask.tony1966.cc
[sudo] tony1966 的密碼:
在最前面添加根目錄 / 的 location 區塊, 轉發對根目錄的請求到 Gunicorn 監聽的 http://127.0.0.1:8000 端口 :
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
按 Ctrl+O 儲存後按 Ctrl+X 跳出 nano 後, 重啟 Nginx 伺服器 :
tony1966@LX2438:~/flask_apps/serverless$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
tony1966@LX2438:~/flask_apps/serverless$ sudo systemctl restart nginx
這時再次訪問登入網址 https://flask.tony1966.cc/login 會出現網頁表單了 :
如果輸入錯誤的密碼 (權杖) 會顯示登入失敗頁面 :
輸入正確的密碼 (權杖) 會顯示登入成功頁面 :
這時按 "查看函式列表" 連結或直接訪問 https://flask.tony1966.cc/function/list_functions 就會顯示函式模組名稱的列表 :
按登出連結會顯示登出頁面 :
這樣伺服端紀錄登入資訊的 session 字典會被刪除, 無法繼續訪問被保護的函式模組了.
5. 新增函式模組功能 :
接下來實作線上新增函式模組功能, 包含下列兩個管理模組 :
- add_function.py :
輸入表單包含模組名稱與函式內容兩個欄位, 使用最簡單樸素的 HTML 表單元素 input 與 textarea 實作即可 (以後再優化 UI), 用 POST 方式提交. - save_function.py :
接收 add_function.py 提交的表單, 將程式碼內容存入 /functions 子目錄下成為指定模組名稱的 Python 檔案.
輸入表單 add_function.py 程式碼如下 :
# add_function.py
def main(request):
html='''
<h2>新增函式模組</h2>
<form action="/function/save_function" method="post">
<label>模組名稱(請用英數字,不含副檔名 .py):</label><br>
<input type="text" name="module_name" size="50" required><br><br>
<label>模組內容(請輸入合語法的 Python 程式碼):</label><br>
<textarea id="code" name="code" rows="20" cols="100" required></textarea><br><br>
<button onclick="location.href='/function/list_functions'">取消</button>
<button type="button" onclick="document.getElementById('code').value = '';">清除</button>
<input type="submit" value="存檔">
</form>
'''
return html
def main(request):
html='''
<h2>新增函式模組</h2>
<form action="/function/save_function" method="post">
<label>模組名稱(請用英數字,不含副檔名 .py):</label><br>
<input type="text" name="module_name" size="50" required><br><br>
<label>模組內容(請輸入合語法的 Python 程式碼):</label><br>
<textarea id="code" name="code" rows="20" cols="100" required></textarea><br><br>
<button onclick="location.href='/function/list_functions'">取消</button>
<button type="button" onclick="document.getElementById('code').value = '';">清除</button>
<input type="submit" value="存檔">
</form>
'''
return html
按下存檔鈕會向 /function/save_function 提交此表單以便將其存成檔案, 程式碼如下 :
# save_function.py
import os
def main(request):
# 僅接受 POST 請求
if request.method != 'POST':
return '<p>只接受 POST 請求</p>'
module_name=request.form.get('module_name', '').strip()
code=request.form.get('code', '').strip()
# 檢查輸入合法性
if not module_name.isidentifier():
return '<p>錯誤:模組名稱須為合法的 Python 識別字</p>'
import os
def main(request):
# 僅接受 POST 請求
if request.method != 'POST':
return '<p>只接受 POST 請求</p>'
module_name=request.form.get('module_name', '').strip()
code=request.form.get('code', '').strip()
# 檢查輸入合法性
if not module_name.isidentifier():
return '<p>錯誤:模組名稱須為合法的 Python 識別字</p>'
if '/' in module_name or '\\' in module_name or '..' in module_name:
return '錯誤:無效的函式名稱' # 避免目錄穿越攻擊(只允許純檔名, 無 / 或 ..)
if not code:return '<p>錯誤:模組內容不得為空</p>'
# 組成檔案路徑
filename=f'./functions/{module_name}.py'
if os.path.exists(filename):
return f'<p>錯誤:模組 <b>{module_name}.py</b> 已存在</p>'
try:
with open(filename, 'w', encoding='utf-8') as f:
f.write(code)
except Exception as e:
return f'<p>儲存失敗:{e}</p>'
return f'''
<p>模組 <b>{module_name}.py</b> 已成功建立</p>
<a href="/function/list_functions">返回函式列表</a>
'''
此程式僅接受 POST 請求, 它會檢查模組名稱是否為合法的 Python 識別字, 且模組內容不可為空. 除此之外還檢查名稱之中是否含有 / 或 .. 字元以避免目錄穿越攻擊 (Directory Traversal Attack, 也稱為路徑穿越攻擊 path traversal), 這是一種網路安全漏洞攻擊手法, 攻擊者會試圖藉由修改 URL 或表單輸入中的 "路徑" 來存取系統上不該讓使用者看到的檔案或目錄.
登入系統後, 訪問 https://flask.tony1966.cc/function/add_function 會顯示輸入表單, 我寫了一個 add.py 函式模組 :
# add.py
def main(request):
try:
# 從 request.args 取得參數 a 和 b,並轉為 int
a = int(request.args.get("a", 0))
b = int(request.args.get("b", 0))
result = a + b
return f"{a} + {b} = {result}"
except (TypeError, ValueError):
return "錯誤:請提供有效的數字參數 a 與 b,例如 /function/add?a=1&b=2"
按存檔鈕顯示成功 :
按返回含是列表連結可見 add 已經入列 :
如果直接按表中的執行鈕會顯示 0+0=0, 因為那連結沒有攜帶參數, 必須於網址列輸入有 a, b 參數的網址例如 : https://flask.tony1966.cc/function/add?a=1&b=2 :
6. 編輯與更新函式模組功能 :
接下來處理編輯與儲存現有函式模組之功能, 前者 edit_function.py 程式碼如下 :
# edit_function.py
# 模組名稱也可更改 -> 相當於新增模組
from flask import render_template_string
import os
def main(request):
module_name=request.args.get('module_name', '') # 取得模組名稱
if not module_name: # 檢查有無傳入模組名稱
return '請指定要編輯的模組名稱,格式 ?module_name=hello'
filename=f'./functions/{module_name}.py'
if not os.path.isfile(filename):
return f'找不到函式檔案:{module_name}'
with open(filename, 'r', encoding='utf-8') as f:
content=f.read() # 讀取模組內容
html=f'''
<h2>編輯函式模組:/functions/{module_name}.py</h2>
<form method="POST" action="/function/update_function">
<label>模組名稱(請用英數字,不含副檔名 .py):</label><br>
<input type="text" name="module_name" size="50" value="{module_name}"><br><br>
<label>模組內容(請輸入合語法的 Python 程式碼):</label><br>
<textarea id="code" name="code" rows="20" cols="100" style="font-family:monospace;">{content}</textarea><br><br>
<button type="button" onclick="location.href='/function/list_functions'">取消</button>
<button type="button" onclick="document.getElementById('code').value = '';">清除</button>
<button type="submit">更新</button>
</form>
'''
return render_template_string(html)
# 模組名稱也可更改 -> 相當於新增模組
from flask import render_template_string
import os
def main(request):
module_name=request.args.get('module_name', '') # 取得模組名稱
if not module_name: # 檢查有無傳入模組名稱
return '請指定要編輯的模組名稱,格式 ?module_name=hello'
filename=f'./functions/{module_name}.py'
if not os.path.isfile(filename):
return f'找不到函式檔案:{module_name}'
with open(filename, 'r', encoding='utf-8') as f:
content=f.read() # 讀取模組內容
html=f'''
<h2>編輯函式模組:/functions/{module_name}.py</h2>
<form method="POST" action="/function/update_function">
<label>模組名稱(請用英數字,不含副檔名 .py):</label><br>
<input type="text" name="module_name" size="50" value="{module_name}"><br><br>
<label>模組內容(請輸入合語法的 Python 程式碼):</label><br>
<textarea id="code" name="code" rows="20" cols="100" style="font-family:monospace;">{content}</textarea><br><br>
<button type="button" onclick="location.href='/function/list_functions'">取消</button>
<button type="button" onclick="document.getElementById('code').value = '';">清除</button>
<button type="submit">更新</button>
</form>
'''
return render_template_string(html)
此處使用 flask.render_template_string() 來渲染字串內嵌的 Jinja2 模板, 因為我們需要在表單欄位中嵌入模組名稱與內容. 程式從請求字串取得模組名稱, 從 /functions 資料夾下讀取模組檔案內容後放在 textarea 元件中. 注意, 模組名稱是可以更改的, 若修改模組名稱, 則相當於建立新模組; 若不修改名稱則是更新模組內容.
例如在 list_functions 頁面中按下 hello 模組的編輯鈕 :
此程式比 add_function.py 多了一個清除鈕, 可方便清除全部內容以利貼上新內容. 按下更新鈕會向 update_function.py 提交表單, 成功顯示如下頁面 :
update_function.py 程式碼如下 :
# update_function.py
import os
def main(request):
if request.method != 'POST':
return '請使用 POST 方法'
module_name=request.form.get('module_name', '').strip()
code=request.form.get('code', '').strip()
# 檢查檔案名稱合法性
if not module_name.isidentifier():
return '<p>錯誤:模組名稱須為合法的 Python 識別字</p>'
import os
def main(request):
if request.method != 'POST':
return '請使用 POST 方法'
module_name=request.form.get('module_name', '').strip()
code=request.form.get('code', '').strip()
# 檢查檔案名稱合法性
if not module_name.isidentifier():
return '<p>錯誤:模組名稱須為合法的 Python 識別字</p>'
if '/' in module_name or '\\' in module_name or '..' in module_name:
return '錯誤:無效的函式名稱' # 避免目錄穿越攻擊(只允許純檔名, 無 / 或 ..)
if not code:return '<p>錯誤:模組內容不得為空</p>'
# 組成檔案路徑
filename=f'./functions/{module_name}.py'
try:
with open(filename, 'w', encoding='utf-8') as f:
f.write(code) # 若檔名一樣覆蓋原內容, 否則建立新模組
return f'模組 {module_name} 已成功更新!<br><br><a href="/function/list_functions">返回函式列表</a>'
except Exception as e:
return f'更新失敗:{e}'
同樣地, 因為有寫入檔案動作, 所以此處與 save_function.py 一樣要檢查名稱之中是否含有 / 或 .. 字元以避免目錄穿越攻擊.
6. 刪除函式模組功能 :
最後來實作刪除函式模組 delete_function.py 功能, 程式碼如下 :
# delete_function.py
import os
def main(request):
module_name=request.args.get('module_name', '').strip()
# 過濾模組名稱
if not module_name:
return '<p>錯誤:未提供模組名稱</p>'
if not module_name.isidentifier():
return '<p>錯誤:模組名稱須為合法的 Python 識別字</p>'
if '/' in module_name or '\\' in module_name or '..' in module_name:
return '錯誤:無效的函式名稱' # 避免目錄穿越攻擊(只允許純檔名, 無 / 或 ..)
filename=f'./functions/{module_name}.py'
if not os.path.exists(filename):
return f'<p>錯誤:模組 {module_name} 不存在</p>'
try:
os.remove(filename) # 刪除檔案
return f'''
<p>已成功刪除模組:{module_name}</p>
<a href="/function/list_functions">返回函式列表</a>
'''
except Exception as e:
return f'<p>刪除失敗:{e}</p>'
此處使用 os.remove() 刪除指定模組檔案.
為了測試此功能, 我先新增了一個從 hello2.py 複製而來的 hello3.py :
按下 hello3.py 那列的刪除連結 :
按返回函式列表可見 hello3 已被刪除 :
這樣就完工啦!
PS:
上面函式列表 list_functions.py 會列出 functions 子目錄下全部模組, 包含管理功能的 5 個模組, 這樣不妥, 可能會不小心刪除管理模組, 這些管理模組應該排除在線上管理之外, 因此我將主程式 severless.py 中的 PROTECTED_FUNCTIONS 串列複製到 list_functions.py 中, 在遍歷 /functions 下的模組時跳過管理模組, 只顯示應用模組, 程式碼修改如下 :
# list_functions.py
import os
def main(request):
# 取得 functions 目錄絕對路徑
functions_dir='./functions'
# 取得所有 .py 檔案(但排除 __init__.py)
try:
files=os.listdir(functions_dir)
py_files=[f[:-3] for f in files if f.endswith('.py') and f != '__init__.py']
py_files.sort() # 按字母順序排序
except FileNotFoundError:
return '<p>directory ./functions not found!</p>'
# 產生 HTML 碼
html = '<h2>函式列表</h2>'
html += '<table border="1" cellspacing="0" cellpadding="6" style="border-collapse: collapse;">'
html += '<tr><th>函式名稱</th><th>執行</th><th>編輯</th><th>刪除</th></tr>'
PROTECTED_FUNCTIONS=['list_functions',
'add_function',
'save_function',
'edit_function',
'update_function',
'delete_function']
for func in py_files:
if func in PROTECTED_FUNCTIONS:
continue
html += f'<tr>'
html += f'<td>{func}</td>'
html += f'<td><a href="/function/{func}">執行</a></td>'
html += f'<td><a href="/function/edit_function?module_name={func}">編輯</a></td>'
html += f'<td><a href="/function/delete_function?module_name={func}">刪除</a></td>'
html += f'</tr>'
html += '</table>'
html += '<br><a href="/function/add_function">新增函式</a> '
html += '<a href="/logout">登出</a>'
return html
import os
def main(request):
# 取得 functions 目錄絕對路徑
functions_dir='./functions'
# 取得所有 .py 檔案(但排除 __init__.py)
try:
files=os.listdir(functions_dir)
py_files=[f[:-3] for f in files if f.endswith('.py') and f != '__init__.py']
py_files.sort() # 按字母順序排序
except FileNotFoundError:
return '<p>directory ./functions not found!</p>'
# 產生 HTML 碼
html = '<h2>函式列表</h2>'
html += '<table border="1" cellspacing="0" cellpadding="6" style="border-collapse: collapse;">'
html += '<tr><th>函式名稱</th><th>執行</th><th>編輯</th><th>刪除</th></tr>'
PROTECTED_FUNCTIONS=['list_functions',
'add_function',
'save_function',
'edit_function',
'update_function',
'delete_function']
for func in py_files:
if func in PROTECTED_FUNCTIONS:
continue
html += f'<tr>'
html += f'<td>{func}</td>'
html += f'<td><a href="/function/{func}">執行</a></td>'
html += f'<td><a href="/function/edit_function?module_name={func}">編輯</a></td>'
html += f'<td><a href="/function/delete_function?module_name={func}">刪除</a></td>'
html += f'</tr>'
html += '</table>'
html += '<br><a href="/function/add_function">新增函式</a> '
html += '<a href="/logout">登出</a>'
return html
我起初以為既然 list_functions.py 是被動態載入主程式 serverless.py 內, 那應該可以直接在 list_functions.py 裡面直接使用 PROTECTED_FUNCTIONS, 但這樣想是錯的, 結果會出現執行錯誤 (未定義), 因為 Python 的模組有各自獨立的命名空間, 在 serverless.py 呼叫 list_functions.main() 時只是執行這個函式而已, list_functions.py 無法自動取得 serverless.py 裡的變數, 必須在呼叫 main() 時明確地把 PROTECTED_FUNCTIONS 傳進去, 但我覺得這樣很麻煩, 直接把串列複製過去更簡單. 訪問 /function/list_functions 結果如下 :
我已將整個系統打包放在 GitHub :
專案目錄網址 :
2025-08-03 補充 :
摘要整理此平台之維護指令 :
sudo systemctl status serverless (顯示系統服務 serverless 狀態)
journalctl -u serverless -n 50 --no-pager (顯示系統服務 serverless 最近 50 筆日誌)
sudo systemctl restart serverless (重啟 serverless 服務)
ps aux | grep gunicorn (顯示執行中的 Gunicorn 程序)




















沒有留言 :
張貼留言