Feat: 파일 시스템 접근 및 디렉토리 탐색 명령어 구현
- FTPFileSystem 클래스 생성: 파일 시스템 추상화 * 안드로이드 외부 저장소의 FTPServer 디렉토리를 루트로 설정 * 경로 보안 검증 (루트 디렉토리 escape 방지) * 상대/절대 경로 지원 - FTPSession에 파일 시스템 통합 * FTPFileSystem 인스턴스 추가 * PWD 명령어를 파일 시스템 기반으로 수정 - 디렉토리 탐색 명령어 구현 * CWD: 디렉토리 변경 * CDUP: 상위 디렉토리로 이동 - 파일 목록 명령어 구현 * LIST: 상세 파일 목록 (Unix 스타일) * NLST: 파일명만 나열 * 임시로 제어 연결을 통해 전송 (데이터 연결은 Phase 4에서 구현) - 파일/디렉토리 관리 명령어 구현 * MKD: 디렉토리 생성 * RMD: 디렉토리 삭제 * DELE: 파일 삭제 * SIZE: 파일 크기 조회 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
271
app/src/main/java/be/gyu/android/server/ftp/FTPFileSystem.java
Normal file
271
app/src/main/java/be/gyu/android/server/ftp/FTPFileSystem.java
Normal file
@@ -0,0 +1,271 @@
|
||||
package be.gyu.android.server.ftp;
|
||||
|
||||
import android.os.Environment;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public class FTPFileSystem {
|
||||
private static final String TAG = "FTPFileSystem";
|
||||
|
||||
private final File rootDirectory;
|
||||
private File currentDirectory;
|
||||
|
||||
public FTPFileSystem() {
|
||||
// Use external storage public directory as root
|
||||
// In production, you might want to use app-specific directory or allow user to choose
|
||||
File externalStorage = Environment.getExternalStorageDirectory();
|
||||
this.rootDirectory = new File(externalStorage, "FTPServer");
|
||||
|
||||
// Create root directory if it doesn't exist
|
||||
if (!rootDirectory.exists()) {
|
||||
if (rootDirectory.mkdirs()) {
|
||||
Log.i(TAG, "Root directory created: " + rootDirectory.getAbsolutePath());
|
||||
} else {
|
||||
Log.w(TAG, "Failed to create root directory, using external storage root");
|
||||
this.rootDirectory = externalStorage;
|
||||
}
|
||||
}
|
||||
|
||||
this.currentDirectory = rootDirectory;
|
||||
Log.d(TAG, "File system initialized. Root: " + rootDirectory.getAbsolutePath());
|
||||
}
|
||||
|
||||
public String getCurrentPath() {
|
||||
String relativePath = getRelativePath(currentDirectory);
|
||||
return relativePath.isEmpty() ? "/" : "/" + relativePath;
|
||||
}
|
||||
|
||||
public boolean changeDirectory(String path) {
|
||||
File newDir;
|
||||
|
||||
if (path.startsWith("/")) {
|
||||
// Absolute path
|
||||
newDir = new File(rootDirectory, path.substring(1));
|
||||
} else {
|
||||
// Relative path
|
||||
newDir = new File(currentDirectory, path);
|
||||
}
|
||||
|
||||
try {
|
||||
String canonicalPath = newDir.getCanonicalPath();
|
||||
String rootPath = rootDirectory.getCanonicalPath();
|
||||
|
||||
// Security check: prevent escaping root directory
|
||||
if (!canonicalPath.startsWith(rootPath)) {
|
||||
Log.w(TAG, "Attempted to escape root directory: " + canonicalPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newDir.exists() && newDir.isDirectory()) {
|
||||
currentDirectory = newDir;
|
||||
Log.d(TAG, "Changed directory to: " + currentDirectory.getAbsolutePath());
|
||||
return true;
|
||||
} else {
|
||||
Log.w(TAG, "Directory does not exist: " + newDir.getAbsolutePath());
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error changing directory: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean changeToParentDirectory() {
|
||||
File parent = currentDirectory.getParentFile();
|
||||
|
||||
if (parent == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String parentPath = parent.getCanonicalPath();
|
||||
String rootPath = rootDirectory.getCanonicalPath();
|
||||
|
||||
// Cannot go above root directory
|
||||
if (!parentPath.startsWith(rootPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
currentDirectory = parent;
|
||||
Log.d(TAG, "Changed to parent directory: " + currentDirectory.getAbsolutePath());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error changing to parent directory: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public List<File> listFiles() {
|
||||
File[] files = currentDirectory.listFiles();
|
||||
List<File> fileList = new ArrayList<>();
|
||||
|
||||
if (files != null) {
|
||||
for (File file : files) {
|
||||
fileList.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
return fileList;
|
||||
}
|
||||
|
||||
public String formatFileList(boolean detailed) {
|
||||
List<File> files = listFiles();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
if (detailed) {
|
||||
// LIST format: Unix-style detailed listing
|
||||
SimpleDateFormat dateFormat = new SimpleDateFormat("MMM dd HH:mm", Locale.US);
|
||||
|
||||
for (File file : files) {
|
||||
sb.append(formatDetailedFile(file, dateFormat)).append("\r\n");
|
||||
}
|
||||
} else {
|
||||
// NLST format: names only
|
||||
for (File file : files) {
|
||||
sb.append(file.getName()).append("\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String formatDetailedFile(File file, SimpleDateFormat dateFormat) {
|
||||
// Format: drwxrwxrwx 1 owner group size date name
|
||||
String permissions = file.isDirectory() ? "drwxr-xr-x" : "-rw-r--r--";
|
||||
String owner = "ftp";
|
||||
String group = "ftp";
|
||||
long size = file.length();
|
||||
String date = dateFormat.format(new Date(file.lastModified()));
|
||||
String name = file.getName();
|
||||
|
||||
return String.format("%s %4d %-8s %-8s %10d %s %s",
|
||||
permissions, 1, owner, group, size, date, name);
|
||||
}
|
||||
|
||||
public boolean makeDirectory(String dirName) {
|
||||
File newDir = new File(currentDirectory, dirName);
|
||||
|
||||
try {
|
||||
String canonicalPath = newDir.getCanonicalPath();
|
||||
String rootPath = rootDirectory.getCanonicalPath();
|
||||
|
||||
if (!canonicalPath.startsWith(rootPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newDir.mkdir()) {
|
||||
Log.i(TAG, "Directory created: " + newDir.getAbsolutePath());
|
||||
return true;
|
||||
} else {
|
||||
Log.w(TAG, "Failed to create directory: " + newDir.getAbsolutePath());
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error creating directory: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean removeDirectory(String dirName) {
|
||||
File dir = new File(currentDirectory, dirName);
|
||||
|
||||
try {
|
||||
String canonicalPath = dir.getCanonicalPath();
|
||||
String rootPath = rootDirectory.getCanonicalPath();
|
||||
|
||||
if (!canonicalPath.startsWith(rootPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dir.exists() && dir.isDirectory() && dir.delete()) {
|
||||
Log.i(TAG, "Directory removed: " + dir.getAbsolutePath());
|
||||
return true;
|
||||
} else {
|
||||
Log.w(TAG, "Failed to remove directory: " + dir.getAbsolutePath());
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error removing directory: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean deleteFile(String fileName) {
|
||||
File file = new File(currentDirectory, fileName);
|
||||
|
||||
try {
|
||||
String canonicalPath = file.getCanonicalPath();
|
||||
String rootPath = rootDirectory.getCanonicalPath();
|
||||
|
||||
if (!canonicalPath.startsWith(rootPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.exists() && file.isFile() && file.delete()) {
|
||||
Log.i(TAG, "File deleted: " + file.getAbsolutePath());
|
||||
return true;
|
||||
} else {
|
||||
Log.w(TAG, "Failed to delete file: " + file.getAbsolutePath());
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error deleting file: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public File getFile(String fileName) {
|
||||
File file = new File(currentDirectory, fileName);
|
||||
|
||||
try {
|
||||
String canonicalPath = file.getCanonicalPath();
|
||||
String rootPath = rootDirectory.getCanonicalPath();
|
||||
|
||||
if (!canonicalPath.startsWith(rootPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return file.exists() ? file : null;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error getting file: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public long getFileSize(String fileName) {
|
||||
File file = getFile(fileName);
|
||||
return (file != null && file.isFile()) ? file.length() : -1;
|
||||
}
|
||||
|
||||
private String getRelativePath(File file) {
|
||||
try {
|
||||
String filePath = file.getCanonicalPath();
|
||||
String rootPath = rootDirectory.getCanonicalPath();
|
||||
|
||||
if (filePath.equals(rootPath)) {
|
||||
return "";
|
||||
} else if (filePath.startsWith(rootPath + File.separator)) {
|
||||
return filePath.substring(rootPath.length() + 1).replace(File.separator, "/");
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error getting relative path: " + e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public File getRootDirectory() {
|
||||
return rootDirectory;
|
||||
}
|
||||
|
||||
public File getCurrentDirectory() {
|
||||
return currentDirectory;
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,12 @@ public class FTPSession implements Runnable {
|
||||
|
||||
private String username;
|
||||
private boolean isAuthenticated = false;
|
||||
private String currentDirectory = "/";
|
||||
private boolean isRunning = true;
|
||||
private FTPFileSystem fileSystem;
|
||||
|
||||
public FTPSession(Socket socket) {
|
||||
this.controlSocket = socket;
|
||||
this.fileSystem = new FTPFileSystem();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -73,6 +74,30 @@ public class FTPSession implements Runnable {
|
||||
case "PWD":
|
||||
handlePwd();
|
||||
break;
|
||||
case "CWD":
|
||||
handleCwd(argument);
|
||||
break;
|
||||
case "CDUP":
|
||||
handleCdup();
|
||||
break;
|
||||
case "LIST":
|
||||
handleList(argument);
|
||||
break;
|
||||
case "NLST":
|
||||
handleNlst(argument);
|
||||
break;
|
||||
case "MKD":
|
||||
handleMkd(argument);
|
||||
break;
|
||||
case "RMD":
|
||||
handleRmd(argument);
|
||||
break;
|
||||
case "DELE":
|
||||
handleDele(argument);
|
||||
break;
|
||||
case "SIZE":
|
||||
handleSize(argument);
|
||||
break;
|
||||
case "NOOP":
|
||||
handleNoop();
|
||||
break;
|
||||
@@ -113,7 +138,144 @@ public class FTPSession implements Runnable {
|
||||
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||
return;
|
||||
}
|
||||
sendResponse(FTPResponse.PATHNAME_CREATED, "\"" + currentDirectory + "\" is current directory");
|
||||
String path = fileSystem.getCurrentPath();
|
||||
sendResponse(FTPResponse.PATHNAME_CREATED, "\"" + path + "\" is current directory");
|
||||
}
|
||||
|
||||
private void handleCwd(String path) throws IOException {
|
||||
if (!isAuthenticated) {
|
||||
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.isEmpty()) {
|
||||
sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No directory specified");
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileSystem.changeDirectory(path)) {
|
||||
sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "Directory changed to " + fileSystem.getCurrentPath());
|
||||
} else {
|
||||
sendResponse(FTPResponse.FILE_UNAVAILABLE, "Failed to change directory");
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCdup() throws IOException {
|
||||
if (!isAuthenticated) {
|
||||
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileSystem.changeToParentDirectory()) {
|
||||
sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "Directory changed to " + fileSystem.getCurrentPath());
|
||||
} else {
|
||||
sendResponse(FTPResponse.FILE_UNAVAILABLE, "Already at root directory");
|
||||
}
|
||||
}
|
||||
|
||||
private void handleList(String path) throws IOException {
|
||||
if (!isAuthenticated) {
|
||||
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||
return;
|
||||
}
|
||||
|
||||
// For now, send file list directly without data connection
|
||||
// Phase 4 will implement proper data connection
|
||||
String fileList = fileSystem.formatFileList(true);
|
||||
|
||||
sendResponse(FTPResponse.FILE_STATUS_OK, "File list follows (inline, data connection not yet implemented)");
|
||||
writer.write(fileList);
|
||||
writer.flush();
|
||||
sendResponse(FTPResponse.CLOSING_DATA_CONNECTION, "Directory send OK");
|
||||
}
|
||||
|
||||
private void handleNlst(String path) throws IOException {
|
||||
if (!isAuthenticated) {
|
||||
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||
return;
|
||||
}
|
||||
|
||||
// For now, send file list directly without data connection
|
||||
String fileList = fileSystem.formatFileList(false);
|
||||
|
||||
sendResponse(FTPResponse.FILE_STATUS_OK, "File list follows (inline, data connection not yet implemented)");
|
||||
writer.write(fileList);
|
||||
writer.flush();
|
||||
sendResponse(FTPResponse.CLOSING_DATA_CONNECTION, "Directory send OK");
|
||||
}
|
||||
|
||||
private void handleMkd(String dirName) throws IOException {
|
||||
if (!isAuthenticated) {
|
||||
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||
return;
|
||||
}
|
||||
|
||||
if (dirName.isEmpty()) {
|
||||
sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No directory name specified");
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileSystem.makeDirectory(dirName)) {
|
||||
String newPath = fileSystem.getCurrentPath() + "/" + dirName;
|
||||
sendResponse(FTPResponse.PATHNAME_CREATED, "\"" + newPath + "\" directory created");
|
||||
} else {
|
||||
sendResponse(FTPResponse.FILE_UNAVAILABLE, "Failed to create directory");
|
||||
}
|
||||
}
|
||||
|
||||
private void handleRmd(String dirName) throws IOException {
|
||||
if (!isAuthenticated) {
|
||||
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||
return;
|
||||
}
|
||||
|
||||
if (dirName.isEmpty()) {
|
||||
sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No directory name specified");
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileSystem.removeDirectory(dirName)) {
|
||||
sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "Directory removed");
|
||||
} else {
|
||||
sendResponse(FTPResponse.FILE_UNAVAILABLE, "Failed to remove directory");
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDele(String fileName) throws IOException {
|
||||
if (!isAuthenticated) {
|
||||
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileName.isEmpty()) {
|
||||
sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No file name specified");
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileSystem.deleteFile(fileName)) {
|
||||
sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "File deleted");
|
||||
} else {
|
||||
sendResponse(FTPResponse.FILE_UNAVAILABLE, "Failed to delete file");
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSize(String fileName) throws IOException {
|
||||
if (!isAuthenticated) {
|
||||
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileName.isEmpty()) {
|
||||
sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No file name specified");
|
||||
return;
|
||||
}
|
||||
|
||||
long size = fileSystem.getFileSize(fileName);
|
||||
if (size >= 0) {
|
||||
sendResponse(FTPResponse.FILE_STATUS, String.valueOf(size));
|
||||
} else {
|
||||
sendResponse(FTPResponse.FILE_UNAVAILABLE, "File not found");
|
||||
}
|
||||
}
|
||||
|
||||
private void handleNoop() throws IOException {
|
||||
|
||||
Reference in New Issue
Block a user