diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b77ecb3 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Copy to .env and fill values +BASE_URL=https://next.omhouse.mydns.jp/ +NC_USER=syuji +NC_PASS=your_password_here +# Optional: override directories +# TARGET_DIR=/Public +# BAK_DIR=/Public/bak diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c53abbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/vendor +/.bundle +.env +vendor/bundle \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..3d8ff3b --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +source 'https://rubygems.org' + +gem 'bundler' +# Nokogiri のみ指定(net-http-dav は rubygems.org に存在しないため除外) +gem 'nokogiri' +gem 'dotenv' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..f009d8a --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,18 @@ +GEM + remote: https://rubygems.org/ + specs: + dotenv (3.1.8) + nokogiri (1.18.9-x64-mingw-ucrt) + racc (~> 1.4) + racc (1.8.1) + +PLATFORMS + x64-mingw-ucrt + +DEPENDENCIES + bundler + dotenv + nokogiri + +BUNDLED WITH + 2.3.26 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f45c111 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# move_old_files + +このスクリプトは Nextcloud の特定フォルダ内で、7 日以上前に更新されたファイルをバックアップ用フォルダへ移動します。 + +前提 +- Ruby 2.3 +- Bundler を使って依存を管理します + +セットアップ + +1. このディレクトリで Bundler を使って依存をインストールします: + +```pwsh +bundle install --path vendor/bundle +``` + +実行 + +```pwsh +bundle exec ruby move_old_files.rb +``` + +注意 +- `BASE_URL`, `NC_USER`, `NC_PASS`, `TARGET_DIR`, `BAK_DIR` はスクリプト内で設定されています。必要に応じて編集してください。 +- 本スクリプトは既存の共有(share) を削除します。挙動を確認してから実行してください。 + +環境変数 (.env) + +1. `./.env.example` をコピーして `.env` を作成し、`NC_PASS` などの値を設定してください。 + +```pwsh +cp .env.example .env +``` + +2. `.env` は `.gitignore` に追加されています。共有しないでください。 diff --git a/move_old_files.rb b/move_old_files.rb new file mode 100644 index 0000000..86ebac9 --- /dev/null +++ b/move_old_files.rb @@ -0,0 +1,174 @@ +#!/usr/bin/env ruby +# coding: utf-8 +require 'bundler/setup' +require 'net/http' +require 'uri' +require 'time' +require 'nokogiri' +require 'dotenv' +Dotenv.load + +# Net::HTTP に PROPFIND と MOVE を定義(未定義の場合のみ) +unless Net::HTTP.const_defined?('Propfind') + class Net::HTTP::Propfind < Net::HTTPRequest + METHOD = 'PROPFIND' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true + end +end + +unless Net::HTTP.const_defined?('Move') + class Net::HTTP::Move < Net::HTTPRequest + METHOD = 'MOVE' + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = true + end +end + +# ===== 設定値 ===== +BASE_URL = ENV['BASE_URL'] || 'https://next.omhouse.mydns.jp/' # Nextcloud ベース URL +NC_USER = ENV['NC_USER'] || 'syuji' +# NC_PASS は .env または環境変数で設定してください(このファイルに平文で書かないでください) +NC_PASS = ENV['NC_PASS'] +TARGET_DIR = '/Public' # WebDAV 上の対象フォルダ +BAK_DIR = '/Public/bak' # 移動先フォルダ(あらかじめ作成しておく) + +# 7日前より古いファイルを移動対象とする +LIMIT_TIME = Time.now - 7 * 24 * 60 * 60 + +# PROPFIND で取得したいプロパティを XML で定義 +PROPFIND_BODY = <<~XML + + + + + + +XML + +def propfind(path, depth = 1) + # path は "/Public" のように先頭にスラッシュを含む想定 + uri = URI.parse(BASE_URL.chomp('/') + "/remote.php/dav/files/#{NC_USER}" + path) + req = Net::HTTP::Propfind.new(uri.request_uri) + req['Depth'] = depth.to_s + req['Content-Type'] = 'text/xml' + req.body = PROPFIND_BODY + req.basic_auth(NC_USER, NC_PASS) + + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.request(req) + end +end + +def move_file(src_href, dest_filename) + src_uri = URI.parse(BASE_URL.chomp('/') + src_href) + dest_path = BAK_DIR + '/' + dest_filename + dest_uri = URI.parse(BASE_URL.chomp('/') + "/remote.php/dav/files/#{NC_USER}" + dest_path) + req = Net::HTTP::Move.new(src_uri.request_uri) + req['Destination'] = dest_uri.to_s + req['Overwrite'] = 'T' + req.basic_auth(NC_USER, NC_PASS) + Net::HTTP.start(src_uri.host, src_uri.port, use_ssl: src_uri.scheme == 'https') do |http| + http.request(req) + end +end + +def fetch_share_id(file_path) + api_uri = URI.parse(BASE_URL.chomp('/') + "/ocs/v2.php/apps/files_sharing/api/v1/shares") + api_uri.query = URI.encode_www_form(path: file_path) + req = Net::HTTP::Get.new(api_uri.request_uri) + req['OCS-APIRequest'] = 'true' + req.basic_auth(NC_USER, NC_PASS) + res = Net::HTTP.start(api_uri.host, api_uri.port, use_ssl: api_uri.scheme == 'https') do |http| + http.request(req) + end + return nil unless [100,200,207].include?(res.code.to_i) + doc = Nokogiri::XML(res.body) + id_elem = doc.at_xpath('//id') + id_elem.text if id_elem +end + +def delete_share(share_id) + del_uri = URI.parse(BASE_URL.chomp('/') + "/ocs/v2.php/apps/files_sharing/api/v1/shares/#{share_id}") + req = Net::HTTP::Delete.new(del_uri.request_uri) + req['OCS-APIRequest'] = 'true' + req.basic_auth(NC_USER, NC_PASS) + Net::HTTP.start(del_uri.host, del_uri.port, use_ssl: del_uri.scheme == 'https') do |http| + http.request(req) + end +end + +def process_files + res = propfind(TARGET_DIR, 1) + return unless res.code.to_i == 207 + + moved_count = 0 + dry_count = 0 + deleted_shares_count = 0 + + doc = Nokogiri::XML(res.body) + doc.remove_namespaces! + doc.xpath('//response').each do |resp| + href = resp.at_xpath('href')&.text + next unless href + # 元ディレクトリ自身はスキップ + next if href.chomp('/').end_with?(TARGET_DIR.chomp('/')) + lastmod = resp.at_xpath('propstat/prop/getlastmodified')&.text + puts "Found resource: #{href}, lastmod: #{lastmod}" + next unless lastmod + + begin + file_time = Time.httpdate(lastmod) + rescue + next + end + next unless file_time < LIMIT_TIME + + # href から WebDAV パスを取り出す + path_part = href.split("/remote.php/dav/files/#{NC_USER}", 2)[1] + next unless path_part + # bak フォルダおよびその配下は除外 + if path_part.start_with?(BAK_DIR) + next + end + + filename = path_part.split('/').last + share_id = fetch_share_id(path_part) + if share_id + if defined?(DRY_RUN) && DRY_RUN + puts "DRY RUN: would delete share #{share_id} for #{path_part}" + deleted_shares_count += 1 + else + delete_share(share_id) + deleted_shares_count += 1 + end + end + + if defined?(DRY_RUN) && DRY_RUN + puts "DRY RUN: would move #{href} -> #{BAK_DIR}/#{filename}" + dry_count += 1 + else + move_file(href, filename) + moved_count += 1 + end + end + puts "\nSummary:" + if defined?(DRY_RUN) && DRY_RUN + puts " would-move: #{dry_count} files" + else + puts " moved: #{moved_count} files" + end + puts " shares-deleted: #{deleted_shares_count}" +end + + +if __FILE__ == $0 + # コマンドラインオプションの簡易パース + if ARGV.include?('--dry-run') || ARGV.include?('-n') + DRY_RUN = true + else + DRY_RUN = false + end + + process_files +end diff --git a/test_webdav.rb b/test_webdav.rb new file mode 100644 index 0000000..6a7cc76 --- /dev/null +++ b/test_webdav.rb @@ -0,0 +1,30 @@ +#!/usr/bin/env ruby +# coding: utf-8 +require 'bundler/setup' +require 'net/http' +require 'uri' +require_relative 'move_old_files' + +# このスクリプトは TARGET_DIR と BAK_DIR が WebDAV 上に存在するかを確認するだけです +# 実行: bundle exec ruby test_webdav.rb + +def exists_on_webdav?(path) + uri = URI.parse(BASE_URL.chomp('/') + "/remote.php/dav/files/#{NC_USER}" + path) + req = Net::HTTP::Propfind.new(uri.request_uri) + req['Depth'] = '0' + req.basic_auth(NC_USER, NC_PASS) + + res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.request(req) + end + # 207 Multi-Status または 200 系を存在と判断 + [200, 207].include?(res.code.to_i) +rescue => e + warn "Error checking #{path}: #{e}" + false +end + +puts "Checking TARGET_DIR: #{TARGET_DIR}" +puts exists_on_webdav?(TARGET_DIR) ? "FOUND" : "NOT FOUND" +puts "Checking BAK_DIR: #{BAK_DIR}" +puts exists_on_webdav?(BAK_DIR) ? "FOUND" : "NOT FOUND" diff --git a/test_webdav_local.rb b/test_webdav_local.rb new file mode 100644 index 0000000..4140528 --- /dev/null +++ b/test_webdav_local.rb @@ -0,0 +1,54 @@ +#!/usr/bin/env ruby +# coding: utf-8 +# External gems を使わずに動くテストスクリプト。 +# move_old_files.rb から定数を読み取って、PROPFIND(Depth:0)で存在確認する。 +require 'net/http' +require 'uri' + +SRC = File.expand_path('move_old_files.rb', __dir__) +contents = File.read(SRC) + +def extract_const(contents, name) + if contents =~ /^\s*#{name}\s*=\s*(['"])(.*?)\1/mi + $2 + else + nil + end +end + +BASE_URL = extract_const(contents, 'BASE_URL') || abort('BASE_URL not found') +NC_USER = extract_const(contents, 'NC_USER') || abort('NC_USER not found') +NC_PASS = extract_const(contents, 'NC_PASS') || abort('NC_PASS not found') +TARGET_DIR = extract_const(contents, 'TARGET_DIR')|| abort('TARGET_DIR not found') +BAK_DIR = extract_const(contents, 'BAK_DIR') || abort('BAK_DIR not found') + +# Net::HTTP::Propfind が未定義なら簡単に定義する +unless defined?(Net::HTTP::Propfind) + class Net::HTTP::Propfind < Net::HTTPRequest + METHOD = 'PROPFIND' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true + end +end + +# 実際に PROPFIND を投げる +def exists_on_webdav?(path) + uri = URI.parse(BASE_URL.chomp('/') + "/remote.php/dav/files/#{NC_USER}" + path) + req = Net::HTTP::Propfind.new(uri.request_uri) + req['Depth'] = '0' + req['Content-Type'] = 'text/xml' + req.body = "" + req.basic_auth(NC_USER, NC_PASS) + res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.request(req) + end + [200,207].include?(res.code.to_i) +rescue => e + warn "Error checking #{path}: #{e.class}: #{e.message}" + false +end + +puts "Checking TARGET_DIR: #{TARGET_DIR}" +puts exists_on_webdav?(TARGET_DIR) ? "FOUND" : "NOT FOUND" +puts "Checking BAK_DIR: #{BAK_DIR}" +puts exists_on_webdav?(BAK_DIR) ? "FOUND" : "NOT FOUND"