WordPress Real Physical Media 插件意外導致圖片路徑錯亂救援日誌

起因

出於好奇我下載了 WordPress Real Physical Media 插件以及它要求安裝的 WordPress Media File Renamer 插件. 這個插件聲稱自己可以通過將文件 (主要是圖片) 的物理路徑和文件在 Real Media Library 中的路徑對應, 優化網站的 SEO 表現.

# 比方說, 運行插件後, 文件會被這樣移動

Old Path: /wp-content/uploads/2022/06/pic-name.png
New Path: /wp-content/uploads/2022/blog-post-name/pic-name.png

雖然知道這個對 SEO 排名沒有多大幫助, 出於好奇我還是下載並執行了該插件.

結果是災難性的, 圖片被丟的亂七八糟, 而且插件和 W3 Total Cache 的壓縮為 WEBP 功能完全不兼容, 所有 WEBP 文件都沒有被移動. 改插件更改了所有媒體庫中文件對應的文件路徑, 卻沒有更改文章中圖片的 Link, 僅僅是做了重定向, 從而大大拖慢了頁面載入速度, 大部分圖片根本不會載入, 或者顯示 404 Not Found.

經過慌亂的尋找之後, 我意識到不管是 WordPress Real Physical Media 還是 WordPress Media File Renamer 都沒有 Undo 按鈕. 又是一番尋找之後我絕望的發現我的 WordPress 沒有任何最近的備份.

文件路徑恢復

我當然不可能就這麼放棄我的博客. 我的第一反應是遠程到服務器, 定位到 /wp-content 並對受影響的 uploads 文件夾做了備份. 「保存案發現場」

然後, 我關掉了兩個肇事的插件, 防止進一步的破壞.

cd wp-content
cp uploads uploads.bak

隨後, 我統計了文件夾中的文件數量.

➜  uploads.bak rdfind  .

Now scanning ".", found 3505 files.
Now have 3505 files in total.
Removed 0 files due to nonunique device and inode.
Total size is 815080083 bytes or 777 MiB
Removed 3149 files due to unique sizes from list.356 files left.
Now eliminating candidates based on first bytes:removed 86 files from list.270 files left.
Now eliminating candidates based on last bytes:removed 6 files from list.264 files left.
Now eliminating candidates based on sha1 checksum:removed 0 files from list.264 files left.
It seems like you have 264 files that are not unique
Totally, 25 MiB can be reduced.
Now making results file results.txt

既然知道了對應關係, 那麼只要按照關係把文件恢復回去即可. 萬分幸運的是, 我在創建博客之初為了調理, 修改了 wordpress 的代碼讓所有上傳的文件都自動重命名為當前時間, 精確到毫秒. 這樣我就可以從文件名反推文件原本的文件夾, 像這樣:

# 像下面這樣 revert change

Current Path: /wp-content/uploads/2022/blog-post-name/2022060807412595.png
Revert to: /wp-content/uploads/2022/06/2022060807412595.png

經過統計, 一共有三千多張圖片, 一個一個去整理絕對不現實. 因此我寫了下面這個腳本來幫我自動化任務:

# revert_changes.py

import os
from shutil import move

base_dir = "/mnt/data/wordpress/wp-content/uploads"
move_cnt = 0
delete_cnt = 0

def check_if_all_digits(s : str, length = 12):
    length = min(length, len(s))
    for c in s[:length]:
        if not c.isdigit():
            return False
    return True


def move_files(current_dir, dryrun):
    global move_cnt
    global delete_cnt
    for file in os.listdir(current_dir):
        f = os.path.join(current_dir, file)
        if os.path.isdir(f) :
            move_files(f, dryrun)
        else:
            if check_if_all_digits(file):
                year = file[0:4]
                month = file[4:6]
                new_dir = os.path.join(base_dir, year, month)
                new_file = os.path.join(new_dir, file)


                if not os.path.exists(new_dir) :
                    os.makedirs(new_dir)

                if not os.path.exists(new_file):
                    if not dryrun:
                        move(f, new_dir)
                    print("%s -> %s" % (f, new_file))
                    move_cnt += 1
                else:
                    if not os.path.samefile(f, new_file):
                        # delete current file
                        if not dryrun:
                            os.remove(f)
                        delete_cnt += 1
                        print("delete %s - %s already exists" % (f, new_file))

move_files(base_dir, dryrun=False)
print("%d files moved" % move_cnt)
print("%d files deleted" % delete_cnt)#

Dryrun 無誤後, 我運行了這個腳本.

➜  python3 revert_changes.py > log.txt

# log.txt
...
1559 files moved
0 files deleted

接著, 我刪除了所有多餘的空文件夾:

find . -type d -empty -delete

再次通過 rdfind 確認:

➜  uploads rdfind .
Now scanning ".", found 3508 files.
Now have 3508 files in total.
Removed 0 files due to nonunique device and inode.
Total size is 815377724 bytes or 778 MiB
Removed 3152 files due to unique sizes from list.356 files left.
Now eliminating candidates based on first bytes:removed 86 files from list.270 files left.
Now eliminating candidates based on last bytes:removed 6 files from list.264 files left.
Now eliminating candidates based on sha1 checksum:removed 0 files from list.264 files left.
It seems like you have 264 files that are not unique
Totally, 25 MiB can be reduced.
Now making results file results.txt

3505 -> 3508, 多出來的三個文件分別是腳本、rdfind 的日誌以及腳本的日誌, 沒有問題.

接著設定文件夾的權限:

chown -R www-data uploads
chgrp -R www-data uploads

關掉緩存後訪問網站, 所有圖片恢復正常, 沒有出現 404 的問題.

數據庫恢復

雖然訪客看到的內容已經恢復正常, 但是因為插件同時修改了媒體庫的路徑的緣故, 媒體庫里所有圖片的路徑都遭到竄改, 導致我移動完圖片過後, 媒體庫中所有圖片都無法加載.

經過一番搜索後我發現, 媒體庫中圖片的路徑儲存在 wp_postmeta 數據表中, 關於附件的儲存格式像下面這樣 (只展示了附件相關的部分):

# meta_id, post_id, meta_key, meta_value
(6314, 1823, '_wp_attached_file', 'relative/path/2022071010060058.png')
(6316, 1824, '_wp_attached_file', 'relative/path/2022071010062426.png')
(6318, 1825, '_wp_attached_file', 'relative/path/2022071010083691.png')
(6320, 1826, '_wp_attached_file', 'relative/path/2022071010095840.png')
(6322, 1827, '_wp_attached_file', 'relative/path/2022071010105244.png')
...

這樣子的話, 我只需要寫一個腳本, 將前面的 path 根據文件名更改掉就好.
出於謹慎, 我先使用 phpmyadmin 分別 dump 了整個數據庫, 和 wp_postmeta 數據表.

接著, 我在上一個 python 腳本的基礎上添加了 sql 部分, 寫出了下面這個腳本:

import pymysql as mysql

HOST = "localhost"
USER = "root"
PASSWD = "wordpress12345678"
DB = "wp"

# init db
db = mysql.connect(host=HOST, user=USER, password=PASSWD, database=DB)
cur = db.cursor()

# fetch data from wp_posts
sql = """SELECT * FROM wp_postmeta
         WHERE meta_key = '_wp_attached_file'"""
cur.execute(sql)
db.commit()
results = cur.fetchall()

# update data
def get_file_name(file_path):
    return file_path.split("/")[-1]

def get_new_file_path(file_name):
    year = file_name[:4]
    month = file_name[4:6]
    return year + "/" + month + "/" + file_name

def check_if_all_digits(s: str, length=12):
    length = min(length, len(s))
    for c in s[:length]:
        if not c.isdigit():
            return False
    return True

cnt = 0
for row in results:
    
    old_filepath = row[3]
    filename = get_file_name(old_filepath)

    if not check_if_all_digits(filename):
        new_filepath = old_filepath
    else:
        new_filepath = get_new_file_path(filename)

    sql = """UPDATE wp_postmeta SET meta_value = '%s' WHERE meta_value = '%s'""" % (
        new_filepath, old_filepath)

    print(sql)
    cur.execute(sql)
    cnt += 1

input("Press anykey to confirm:")

try:
    db.commit()
    print("Success, updated", cnt, "rows")
except:
    db.rollback()
    print("Failed, all changes rolled back")

Dryrun 一遍確認無誤, 我執行了腳本:

➜  python3 db_revert.py > log.txt

# log.txt
...
UPDATE wp_postmeta SET meta_value = '2022/07/2022071011161960.png' WHERE meta_value = '2022071011161960.png'
UPDATE wp_postmeta SET meta_value = '2022/07/2022071011254773.png' WHERE meta_value = '2022071011254773.png'
UPDATE wp_postmeta SET meta_value = '2022/07/2022071011285432.png' WHERE meta_value = '2022071011285432.png'
Success, updated 533 rows

我接著回到後台的媒體庫確認更改, 發現 revert change 很成功, 所有圖片都加載出來了.

亡羊補牢

這樣的話, 這次的危機就成功度過了. 總結下來是因為我儲存的信息存在冗余, 可以通過文件名恢復路徑. 如果文件名不是用時間命名的話, 我可能就得被迫解析博文中出現的圖片路徑來恢復圖片了.

這次危機除了是因為我亂裝插件導致的以外, 還有一大誘因就是因為我沒有可靠的 wordpress 專屬備份機制. 我安裝了 updraft plus 插件來幫助我在之後的事故中能返回上一個版本.

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。