diff --git a/gui.py b/gui.py index f233ebf..6699426 100644 --- a/gui.py +++ b/gui.py @@ -11,6 +11,8 @@ import mimetypes import urllib.parse import re import shutil +import socket +import ssl def normalize_file_path(path_str: str) -> str: if not path_str: @@ -587,7 +589,7 @@ class DraggableImageLabel(QLabel): def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: - self.drag_start_pos = event.pos() + self.drag_start_pos = event.position().toPoint() super().mousePressEvent(event) def mouseMoveEvent(self, event): @@ -595,7 +597,7 @@ class DraggableImageLabel(QLabel): return if self.drag_start_pos is None: return - if (event.pos() - self.drag_start_pos).manhattanLength() < QApplication.startDragDistance(): + if (event.position().toPoint() - self.drag_start_pos).manhattanLength() < QApplication.startDragDistance(): return if self.file_path and os.path.exists(self.file_path): @@ -1012,7 +1014,40 @@ def play_success_sound(audio_path=None, volume=None): except Exception: pass -class CurlUploadThread(QThread): +class StreamingMultipartEncoder: + def __init__(self, fields, file_key, file_path): + self.boundary = f"----WebKitFormBoundary{time.time_ns():x}" + self.file_path = file_path + self.file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0 + self.fields = fields + self.file_key = file_key + + body_parts = [] + for key, val in fields.items(): + if val is not None and str(val) != "": + body_parts.append(f"--{self.boundary}\r\nContent-Disposition: form-data; name=\"{key}\"\r\n\r\n{val}\r\n".encode('utf-8')) + + filename = os.path.basename(file_path) + mime_type, _ = mimetypes.guess_type(file_path) + if not mime_type: + ext = os.path.splitext(file_path)[1].lower() + if ext in ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']: + mime_type = f"image/{ext.lstrip('.')}" + if mime_type == "image/jpg": mime_type = "image/jpeg" + elif ext in ['.mp4', '.webm', '.mkv', '.mov', '.avi']: + mime_type = f"video/{ext.lstrip('.')}" + else: + mime_type = "image/png" + + file_header = f"--{self.boundary}\r\nContent-Disposition: form-data; name=\"{self.file_key}\"; filename=\"{filename}\"\r\nContent-Type: {mime_type}\r\n\r\n".encode('utf-8') + file_footer = f"\r\n--{self.boundary}--\r\n".encode('utf-8') + + self.header_bytes = b"".join(body_parts) + file_header + self.footer_bytes = file_footer + self.total_size = len(self.header_bytes) + self.file_size + len(self.footer_bytes) + self.content_type = f"multipart/form-data; boundary={self.boundary}" + +class StreamingUploadThread(QThread): progress = Signal(int, int) # bytes_sent, bytes_total finished = Signal(bool, str) # success, response_or_error_msg @@ -1025,93 +1060,146 @@ class CurlUploadThread(QThread): self.tags = tags self.visibility = visibility self.is_oc = is_oc - self.process = None - self.file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0 self.aborted = False + self.sock = None def abort(self): self.aborted = True - if self.process: + if self.sock: try: - self.process.kill() + self.sock.close() except Exception: pass def run(self): - mime_type, _ = mimetypes.guess_type(self.file_path) - if not mime_type: - ext = os.path.splitext(self.file_path)[1].lower() - if ext in ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']: - mime_type = f"image/{ext.lstrip('.')}" - if mime_type == "image/jpg": - mime_type = "image/jpeg" - elif ext in ['.mp4', '.webm', '.mkv', '.mov', '.avi']: - mime_type = f"video/{ext.lstrip('.')}" - else: - mime_type = "image/png" - - file_param = f"file=@{self.file_path}" - if mime_type: - file_param += f";type={mime_type}" - - cmd = [ - "curl", "-#", - "-F", file_param - ] - if self.api_key: - cmd.extend(["-H", f"X-Api-Key: {self.api_key}"]) - if self.rating: - cmd.extend(["-F", f"rating={self.rating}"]) - if self.tags: - cmd.extend(["-F", f"tags={self.tags}"]) - if self.visibility != "": - cmd.extend(["-F", f"visibility={self.visibility}"]) - if self.is_oc != "0": - cmd.extend(["-F", f"is_oc={self.is_oc}"]) - - cmd.append(self.api_url) - + fields = { + "rating": self.rating, + "tags": self.tags, + "visibility": self.visibility, + "is_oc": self.is_oc + } + try: - self.process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=1 - ) + encoder = StreamingMultipartEncoder(fields, "file", self.file_path) + parsed_url = urllib.parse.urlparse(self.api_url) + + host = parsed_url.hostname + port = parsed_url.port or (443 if parsed_url.scheme == "https" else 80) + path = parsed_url.path or "/" + if parsed_url.query: + path += "?" + parsed_url.query - buffer = "" - while True: - char = self.process.stderr.read(1) - if not char and self.process.poll() is not None: + raw_sock = socket.create_connection((host, port), timeout=30) + if parsed_url.scheme == "https": + ctx = ssl.create_default_context() + self.sock = ctx.wrap_socket(raw_sock, server_hostname=host) + else: + self.sock = raw_sock + + req_headers = ( + f"POST {path} HTTP/1.1\r\n" + f"Host: {host}\r\n" + f"User-Agent: f0ckm-uploader/2.0\r\n" + f"X-Api-Key: {self.api_key}\r\n" + f"Content-Type: {encoder.content_type}\r\n" + f"Content-Length: {encoder.total_size}\r\n" + f"Connection: close\r\n\r\n" + ).encode('utf-8') + + self.sock.sendall(req_headers) + + total_sent = 0 + self.sock.sendall(encoder.header_bytes) + total_sent += len(encoder.header_bytes) + self.progress.emit(total_sent, encoder.total_size) + + chunk_size = 32768 + with open(self.file_path, "rb") as f: + while True: + if self.aborted: + self.finished.emit(False, "ABORTED") + return + chunk = f.read(chunk_size) + if not chunk: + break + + self.sock.sendall(chunk) + total_sent += len(chunk) + + pct = min(99, int((total_sent / encoder.total_size) * 100)) + bytes_sent_calc = int(encoder.total_size * (pct / 100.0)) + self.progress.emit(bytes_sent_calc, encoder.total_size) + time.sleep(0.005) + + self.sock.sendall(encoder.footer_bytes) + total_sent += len(encoder.footer_bytes) + self.progress.emit(encoder.total_size, encoder.total_size) + + response_data = bytearray() + while not self.aborted: + data = self.sock.recv(4096) + if not data: break - if char in ("\r", "\n"): - matches = re.findall(r'(\d+(?:\.\d+)?)\s*%', buffer) - if matches: - try: - pct = float(matches[-1]) - bytes_sent = int(self.file_size * (pct / 100.0)) - self.progress.emit(bytes_sent, self.file_size) - except Exception: - pass - buffer = "" - else: - buffer += char - - stdout_data, stderr_data = self.process.communicate() - exit_code = self.process.returncode + response_data.extend(data) if self.aborted: self.finished.emit(False, "ABORTED") - elif exit_code == 0: - self.finished.emit(True, stdout_data) + return + + parts = response_data.split(b"\r\n\r\n", 1) + headers_str = parts[0].decode('utf-8', errors='replace') if parts else "" + raw_body = parts[1].decode('utf-8', errors='replace') if len(parts) > 1 else response_data.decode('utf-8', errors='replace') + status_line = headers_str.split("\r\n")[0] if headers_str else "" + + # De-chunk HTTP/1.1 response body if Transfer-Encoding: chunked or chunk-prefixed + body = raw_body + if "transfer-encoding: chunked" in headers_str.lower() or re.match(r'^[0-9a-fA-F]+\r\n', raw_body.lstrip()): + decoded_chunks = [] + rest = raw_body.lstrip() + try: + while rest: + lines = rest.split("\r\n", 1) + if len(lines) < 2: + break + hex_str = lines[0].strip().split(";")[0] + try: + chunk_len = int(hex_str, 16) + except ValueError: + break + if chunk_len == 0: + break + chunk_content = lines[1][:chunk_len] + decoded_chunks.append(chunk_content) + rest = lines[1][chunk_len:] + if rest.startswith("\r\n"): + rest = rest[2:] + if decoded_chunks: + body = "".join(decoded_chunks) + except Exception: + pass + + # Extra safety fallback: extract JSON substring if there's any hex prefix left + if not body.strip().startswith("{") and "{" in body and "}" in body: + match = re.search(r'\{.*\}', body, re.DOTALL) + if match: + body = match.group(0) + + if "200" in status_line or "201" in status_line: + self.finished.emit(True, body) else: - self.finished.emit(False, stderr_data or f"curl exited with code {exit_code}") + self.finished.emit(False, body or status_line) + except Exception as e: if self.aborted: self.finished.emit(False, "ABORTED") else: self.finished.emit(False, str(e)) + finally: + if self.sock: + try: + self.sock.close() + except Exception: + pass # ========================================== # System Tray Application Manager @@ -1238,6 +1326,12 @@ class SystemTrayApp(QObject): if os.path.exists(save_path) and os.path.getsize(save_path) > 0: self.upload_file_direct(save_path) + def on_abort_upload(self): + if self.current_reply and self.current_reply.isRunning(): + self.current_reply.abort() + elif hasattr(self, "upload_thread") and self.upload_thread: + self.upload_thread.abort() + def upload_file_direct(self, file_path): file_path = normalize_file_path(file_path) if not file_path or not os.path.exists(file_path): @@ -1247,10 +1341,13 @@ class SystemTrayApp(QObject): config = get_env_config() api_url = config.get("api_url", "") api_key = config.get("api_key", "") + if not api_url or not api_key: + self.show_message("Upload Error", "API URL and API Key must be set in Settings.", QSystemTrayIcon.Critical) + return + rating = config.get("default_rating", "") tags = config.get("default_tags", "") if tags: - # Format tags as clean comma-separated string ("tag1, tag2") for f0ckm API tagsRaw.split(',') compatibility tag_list = [t.strip() for t in re.split(r'[,]+', tags) if t.strip()] tags = ", ".join(tag_list) @@ -1264,13 +1361,28 @@ class SystemTrayApp(QObject): self.tray.setIcon(create_digit_icon(0)) self.tray.setToolTip(f"Uploading {filename}...\n0% (0 B)") - self.upload_thread = CurlUploadThread(api_url, api_key, file_path, rating, tags, visibility, is_oc) + self.upload_thread = StreamingUploadThread(api_url, api_key, file_path, rating, tags, visibility, is_oc) self.upload_thread.progress.connect(self.on_upload_progress) self.upload_thread.finished.connect(lambda ok, resp: self.on_upload_finished(ok, resp, file_path)) self.upload_thread.start() self.abort_action.setEnabled(True) + def _on_qt_reply_finished(self, reply, file_path): + self.current_reply = None + err = reply.error() + if err == QNetworkReply.NetworkError.NoError: + resp_bytes = reply.readAll().data() + resp_str = resp_bytes.decode('utf-8', errors='replace') + self.on_upload_finished(True, resp_str, file_path) + else: + if err == QNetworkReply.NetworkError.OperationCanceledError: + self.on_upload_finished(False, "ABORTED", file_path) + else: + resp_bytes = reply.readAll().data() + resp_str = resp_bytes.decode('utf-8', errors='replace') if resp_bytes else reply.errorString() + self.on_upload_finished(False, resp_str, file_path) + def on_upload_progress(self, bytes_sent, bytes_total): if bytes_total > 0: pct = min(100, max(0, int((bytes_sent / bytes_total) * 100)))