From 9050faf0a403bc0cd0bece711bc21d16af9305c7 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Thu, 13 Aug 2026 17:21:58 +0200 Subject: [PATCH] update browser extension and app --- .../main/java/com/fuggloader/app/Instance.kt | 42 ++++++ .../java/com/fuggloader/app/MainActivity.kt | 101 +++++++++++-- .../com/fuggloader/app/PreferencesManager.kt | 113 ++++++++++++-- .../java/com/fuggloader/app/ShareActivity.kt | 46 ++++-- .../com/fuggloader/app/UploaderService.kt | 53 ++++--- .../app/src/main/res/layout/activity_main.xml | 67 ++++++++- browser-extension/README.md | 58 +++++++- browser-extension/background.js | 120 +++++++++++++-- browser-extension/manifest.json | 6 + browser-extension/options/options.html | 4 + browser-extension/options/options.js | 68 ++++++++- browser-extension/popup/popup.css | 69 ++++++++- browser-extension/popup/popup.html | 19 +++ browser-extension/popup/popup.js | 140 ++++++++++++++++-- 14 files changed, 809 insertions(+), 97 deletions(-) create mode 100644 android/app/src/main/java/com/fuggloader/app/Instance.kt diff --git a/android/app/src/main/java/com/fuggloader/app/Instance.kt b/android/app/src/main/java/com/fuggloader/app/Instance.kt new file mode 100644 index 0000000..c4cce91 --- /dev/null +++ b/android/app/src/main/java/com/fuggloader/app/Instance.kt @@ -0,0 +1,42 @@ +package com.fuggloader.app + +import org.json.JSONObject +import java.util.UUID + +data class Instance( + val id: String = UUID.randomUUID().toString(), + var name: String, + var apiUrl: String, + var apiKey: String, + var defaultRating: String = "none", + var defaultTags: String = "", + var defaultVisibility: String = "0" +) { + fun toJsonObject(): JSONObject { + return JSONObject().apply { + put("id", id) + put("name", name) + put("apiUrl", apiUrl) + put("apiKey", apiKey) + put("defaultRating", defaultRating) + put("defaultTags", defaultTags) + put("defaultVisibility", defaultVisibility) + } + } + + override fun toString(): String = name + + companion object { + fun fromJsonObject(json: JSONObject): Instance { + return Instance( + id = json.optString("id", UUID.randomUUID().toString()), + name = json.optString("name", "Default"), + apiUrl = json.optString("apiUrl", ""), + apiKey = json.optString("apiKey", ""), + defaultRating = json.optString("defaultRating", "none"), + defaultTags = json.optString("defaultTags", ""), + defaultVisibility = json.optString("defaultVisibility", "0") + ) + } + } +} diff --git a/android/app/src/main/java/com/fuggloader/app/MainActivity.kt b/android/app/src/main/java/com/fuggloader/app/MainActivity.kt index 72e447d..44b897b 100644 --- a/android/app/src/main/java/com/fuggloader/app/MainActivity.kt +++ b/android/app/src/main/java/com/fuggloader/app/MainActivity.kt @@ -2,6 +2,8 @@ package com.fuggloader.app import android.os.Bundle import android.util.Log +import android.view.View +import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.Toast import androidx.appcompat.app.AppCompatActivity @@ -9,13 +11,15 @@ import com.fuggloader.app.databinding.ActivityMainBinding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var prefs: PreferencesManager private lateinit var uploader: UploaderService + + private lateinit var instanceAdapter: ArrayAdapter + private var currentInstances: List = emptyList() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -27,7 +31,7 @@ class MainActivity : AppCompatActivity() { setupRatingSpinner() setupVisibilitySpinner() - loadSettings() + setupInstanceSpinner() binding.btnSave.setOnClickListener { saveSettings() @@ -35,9 +39,16 @@ class MainActivity : AppCompatActivity() { } binding.btnTestConnection.setOnClickListener { - saveSettings() testConnection() } + + binding.btnAddInstance.setOnClickListener { + addNewInstance() + } + + binding.btnDeleteInstance.setOnClickListener { + deleteCurrentInstance() + } } private fun setupRatingSpinner() { @@ -52,19 +63,45 @@ class MainActivity : AppCompatActivity() { binding.spinnerVisibility.adapter = adapter } - private fun loadSettings() { - binding.etApiUrl.setText(prefs.apiUrl) - binding.etApiKey.setText(prefs.apiKey) - binding.etTags.setText(prefs.defaultTags) + private fun setupInstanceSpinner() { + currentInstances = prefs.getInstances() + if (currentInstances.isEmpty()) { + val default = Instance(name = "Default", apiUrl = "", apiKey = "") + prefs.addInstance(default) + currentInstances = prefs.getInstances() + } - when (prefs.defaultRating) { + instanceAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, currentInstances) + binding.spinnerInstances.adapter = instanceAdapter + + val activeId = prefs.activeInstanceId + val activeIndex = currentInstances.indexOfFirst { it.id == activeId }.coerceAtLeast(0) + binding.spinnerInstances.setSelection(activeIndex) + + binding.spinnerInstances.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + val selected = currentInstances[position] + prefs.activeInstanceId = selected.id + loadInstanceData(selected) + } + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + } + + private fun loadInstanceData(instance: Instance) { + binding.etInstanceName.setText(instance.name) + binding.etApiUrl.setText(instance.apiUrl) + binding.etApiKey.setText(instance.apiKey) + binding.etTags.setText(instance.defaultTags) + + when (instance.defaultRating) { "sfw" -> binding.spinnerRating.setSelection(1) "nsfw" -> binding.spinnerRating.setSelection(2) "nsfl" -> binding.spinnerRating.setSelection(3) else -> binding.spinnerRating.setSelection(0) } - when (prefs.defaultVisibility) { + when (instance.defaultVisibility) { "1" -> binding.spinnerVisibility.setSelection(1) "2" -> binding.spinnerVisibility.setSelection(2) else -> binding.spinnerVisibility.setSelection(0) @@ -72,22 +109,54 @@ class MainActivity : AppCompatActivity() { } private fun saveSettings() { - prefs.apiUrl = binding.etApiUrl.text.toString().trim() - prefs.apiKey = binding.etApiKey.text.toString().trim() - prefs.defaultTags = binding.etTags.text.toString().trim() + val currentInstance = prefs.getActiveInstance() ?: return + + currentInstance.name = binding.etInstanceName.text.toString().trim().ifBlank { "Untitled" } + currentInstance.apiUrl = binding.etApiUrl.text.toString().trim() + currentInstance.apiKey = binding.etApiKey.text.toString().trim() + currentInstance.defaultTags = binding.etTags.text.toString().trim() - prefs.defaultRating = when (binding.spinnerRating.selectedItemPosition) { + currentInstance.defaultRating = when (binding.spinnerRating.selectedItemPosition) { 1 -> "sfw" 2 -> "nsfw" 3 -> "nsfl" else -> "none" } - prefs.defaultVisibility = binding.spinnerVisibility.selectedItemPosition.toString() + currentInstance.defaultVisibility = binding.spinnerVisibility.selectedItemPosition.toString() + + prefs.updateInstance(currentInstance) + + // Refresh spinner to show updated name + val pos = binding.spinnerInstances.selectedItemPosition + currentInstances = prefs.getInstances() + instanceAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, currentInstances) + binding.spinnerInstances.adapter = instanceAdapter + binding.spinnerInstances.setSelection(pos) + } + + private fun addNewInstance() { + val newInstance = Instance(name = "New Instance", apiUrl = "", apiKey = "") + prefs.addInstance(newInstance) + prefs.activeInstanceId = newInstance.id + setupInstanceSpinner() + } + + private fun deleteCurrentInstance() { + val current = prefs.getActiveInstance() ?: return + if (currentInstances.size <= 1) { + Toast.makeText(this, "Cannot delete the only instance", Toast.LENGTH_SHORT).show() + return + } + + prefs.deleteInstance(current.id) + setupInstanceSpinner() + Toast.makeText(this, "Instance deleted", Toast.LENGTH_SHORT).show() } private fun testConnection() { - if (!prefs.isConfigured()) { + val currentInstance = prefs.getActiveInstance() + if (currentInstance == null || currentInstance.apiUrl.isBlank() || currentInstance.apiKey.isBlank()) { Toast.makeText(this, "Please enter both API URL and API Key", Toast.LENGTH_LONG).show() return } @@ -96,7 +165,7 @@ class MainActivity : AppCompatActivity() { binding.btnTestConnection.text = "Testing..." CoroutineScope(Dispatchers.Main).launch { - val result = uploader.testConnection() + val result = uploader.testConnection(currentInstance) binding.btnTestConnection.isEnabled = true binding.btnTestConnection.text = "Test" diff --git a/android/app/src/main/java/com/fuggloader/app/PreferencesManager.kt b/android/app/src/main/java/com/fuggloader/app/PreferencesManager.kt index f3bbb45..e0bc0bf 100644 --- a/android/app/src/main/java/com/fuggloader/app/PreferencesManager.kt +++ b/android/app/src/main/java/com/fuggloader/app/PreferencesManager.kt @@ -2,31 +2,112 @@ package com.fuggloader.app import android.content.Context import android.content.SharedPreferences +import org.json.JSONArray class PreferencesManager(context: Context) { private val prefs: SharedPreferences = context.getSharedPreferences("fuggloader_prefs", Context.MODE_PRIVATE) - var apiUrl: String - get() = prefs.getString("api_url", "") ?: "" - set(value) = prefs.edit().putString("api_url", value).apply() + companion object { + private const val KEY_INSTANCES = "instances_json" + private const val KEY_ACTIVE_INSTANCE_ID = "active_instance_id" - var apiKey: String - get() = prefs.getString("api_key", "") ?: "" - set(value) = prefs.edit().putString("api_key", value).apply() + // Old keys for migration + private const val OLD_API_URL = "api_url" + private const val OLD_API_KEY = "api_key" + private const val OLD_RATING = "default_rating" + private const val OLD_TAGS = "default_tags" + private const val OLD_VISIBILITY = "default_visibility" + } - var defaultRating: String - get() = prefs.getString("default_rating", "none") ?: "none" - set(value) = prefs.edit().putString("default_rating", value).apply() + var activeInstanceId: String? + get() = prefs.getString(KEY_ACTIVE_INSTANCE_ID, null) + set(value) = prefs.edit().putString(KEY_ACTIVE_INSTANCE_ID, value).apply() - var defaultTags: String - get() = prefs.getString("default_tags", "android,upload") ?: "android,upload" - set(value) = prefs.edit().putString("default_tags", value).apply() + fun getInstances(): List { + val jsonStr = prefs.getString(KEY_INSTANCES, null) + if (jsonStr == null) { + // Check for migration + val oldUrl = prefs.getString(OLD_API_URL, null) + if (oldUrl != null) { + val instance = Instance( + name = "Default", + apiUrl = oldUrl, + apiKey = prefs.getString(OLD_API_KEY, "") ?: "", + defaultRating = prefs.getString(OLD_RATING, "none") ?: "none", + defaultTags = prefs.getString(OLD_TAGS, "android,upload") ?: "android,upload", + defaultVisibility = prefs.getString(OLD_VISIBILITY, "0") ?: "0" + ) + val list = listOf(instance) + saveInstances(list) + activeInstanceId = instance.id - var defaultVisibility: String - get() = prefs.getString("default_visibility", "0") ?: "0" - set(value) = prefs.edit().putString("default_visibility", value).apply() + // Clear old settings to avoid re-migration + prefs.edit() + .remove(OLD_API_URL) + .remove(OLD_API_KEY) + .remove(OLD_RATING) + .remove(OLD_TAGS) + .remove(OLD_VISIBILITY) + .apply() + + return list + } + return emptyList() + } + + val list = mutableListOf() + try { + val array = JSONArray(jsonStr) + for (i in 0 until array.length()) { + list.add(Instance.fromJsonObject(array.getJSONObject(i))) + } + } catch (e: Exception) { + e.printStackTrace() + } + return list + } + + fun saveInstances(instances: List) { + val array = JSONArray() + instances.forEach { array.put(it.toJsonObject()) } + prefs.edit().putString(KEY_INSTANCES, array.toString()).apply() + } + + fun addInstance(instance: Instance) { + val instances = getInstances().toMutableList() + instances.add(instance) + saveInstances(instances) + } + + fun updateInstance(instance: Instance) { + val instances = getInstances().toMutableList() + val index = instances.indexOfFirst { it.id == instance.id } + if (index != -1) { + instances[index] = instance + saveInstances(instances) + } + } + + fun deleteInstance(id: String) { + val instances = getInstances().toMutableList() + instances.removeAll { it.id == id } + saveInstances(instances) + if (activeInstanceId == id) { + activeInstanceId = instances.firstOrNull()?.id + } + } + + fun getActiveInstance(): Instance? { + val instances = getInstances() + if (instances.isEmpty()) return null + + val id = activeInstanceId ?: return instances.firstOrNull() + return instances.find { it.id == id } ?: instances.firstOrNull() + } fun isConfigured(): Boolean { - return apiUrl.isNotBlank() && apiKey.isNotBlank() + return getActiveInstance()?.let { + it.apiUrl.isNotBlank() && it.apiKey.isNotBlank() + } ?: false } } diff --git a/android/app/src/main/java/com/fuggloader/app/ShareActivity.kt b/android/app/src/main/java/com/fuggloader/app/ShareActivity.kt index a7284e7..4cf63d6 100644 --- a/android/app/src/main/java/com/fuggloader/app/ShareActivity.kt +++ b/android/app/src/main/java/com/fuggloader/app/ShareActivity.kt @@ -8,6 +8,7 @@ import android.net.Uri import android.os.Bundle import android.view.View import android.widget.Toast +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import com.fuggloader.app.databinding.ActivityShareBinding import kotlinx.coroutines.CoroutineScope @@ -57,10 +58,32 @@ class ShareActivity : AppCompatActivity() { finish() } - handleShareIntent(intent) + prepareShare(intent) } - private fun handleShareIntent(intent: Intent) { + private fun prepareShare(intent: Intent) { + val instances = prefs.getInstances() + if (instances.size > 1) { + val names = instances.map { it.name }.toTypedArray() + AlertDialog.Builder(this) + .setTitle("Select Instance") + .setItems(names) { _, which -> + handleShareIntent(instances[which], intent) + } + .setNegativeButton("Cancel") { _, _ -> finish() } + .setCancelable(false) + .show() + } else { + val target = instances.firstOrNull() + if (target != null) { + handleShareIntent(target, intent) + } else { + showError("No instance configured") + } + } + } + + private fun handleShareIntent(instance: Instance, intent: Intent) { val action = intent.action val type = intent.type @@ -69,11 +92,11 @@ class ShareActivity : AppCompatActivity() { val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) if (!sharedText.isNull_or_blank_url()) { val targetUrl = extractUrl(sharedText!!) - processUrlUpload(targetUrl) + processUrlUpload(instance, targetUrl) } else { val streamUri = intent.getParcelableExtra(Intent.EXTRA_STREAM) if (streamUri != null) { - processFileUpload(streamUri) + processFileUpload(instance, streamUri) } else { showError("No valid file or URL found in share intent") } @@ -81,7 +104,7 @@ class ShareActivity : AppCompatActivity() { } else { val streamUri = intent.getParcelableExtra(Intent.EXTRA_STREAM) if (streamUri != null) { - processFileUpload(streamUri) + processFileUpload(instance, streamUri) } else { showError("No stream URI found for file share") } @@ -90,7 +113,7 @@ class ShareActivity : AppCompatActivity() { val uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) if (!uris.isNullOrEmpty()) { // Upload first file in batch for now - processFileUpload(uris[0]) + processFileUpload(instance, uris[0]) } else { showError("No files found in multi-share intent") } @@ -116,8 +139,8 @@ class ShareActivity : AppCompatActivity() { return trimmed } - private fun processFileUpload(uri: Uri) { - binding.tvStatus.text = "Uploading file..." + private fun processFileUpload(instance: Instance, uri: Uri) { + binding.tvStatus.text = "Uploading to ${instance.name}..." binding.progressBar.visibility = View.VISIBLE binding.progressBar.isIndeterminate = false binding.progressBar.progress = 0 @@ -126,6 +149,7 @@ class ShareActivity : AppCompatActivity() { val result = uploader.uploadFile( contentResolver = contentResolver, uri = uri, + instance = instance, onProgress = { pct -> runOnUiThread { binding.progressBar.progress = pct @@ -141,14 +165,14 @@ class ShareActivity : AppCompatActivity() { } } - private fun processUrlUpload(url: String) { - binding.tvStatus.text = "Uploading URL..." + private fun processUrlUpload(instance: Instance, url: String) { + binding.tvStatus.text = "Uploading to ${instance.name}..." binding.progressBar.visibility = View.VISIBLE binding.progressBar.isIndeterminate = true binding.tvPercent.text = "" CoroutineScope(Dispatchers.Main).launch { - val result = uploader.uploadUrl(url) + val result = uploader.uploadUrl(url, instance) result.fold( onSuccess = { postUrl -> showSuccess(postUrl) }, onFailure = { err -> showError(err.message ?: "URL Upload failed") } diff --git a/android/app/src/main/java/com/fuggloader/app/UploaderService.kt b/android/app/src/main/java/com/fuggloader/app/UploaderService.kt index 5930e8f..ba1eb49 100644 --- a/android/app/src/main/java/com/fuggloader/app/UploaderService.kt +++ b/android/app/src/main/java/com/fuggloader/app/UploaderService.kt @@ -29,12 +29,13 @@ class UploaderService(private val prefs: PreferencesManager) { suspend fun uploadFile( contentResolver: ContentResolver, uri: Uri, - rating: String = prefs.defaultRating, - tags: String = prefs.defaultTags, - visibility: String = prefs.defaultVisibility, + instance: Instance? = null, onProgress: (percent: Int) -> Unit ): Result = withContext(Dispatchers.IO) { - Log.d("UploaderService", "Starting uploadFile for $uri") + val targetInstance = instance ?: prefs.getActiveInstance() + ?: return@withContext Result.failure(Exception("No instance configured")) + + Log.d("UploaderService", "Starting uploadFile for $uri to ${targetInstance.apiUrl}") try { val fileName = getFileName(contentResolver, uri) ?: "upload_${System.currentTimeMillis()}" val mimeType = contentResolver.getType(uri) ?: "application/octet-stream" @@ -69,16 +70,16 @@ class UploaderService(private val prefs: PreferencesManager) { .setType(MultipartBody.FORM) .addFormDataPart("file", fileName, requestBody) - if (rating != "none") { - multipartBuilder.addFormDataPart("rating", rating) + if (targetInstance.defaultRating != "none") { + multipartBuilder.addFormDataPart("rating", targetInstance.defaultRating) } - multipartBuilder.addFormDataPart("tags", tags) - .addFormDataPart("visibility", visibility) + multipartBuilder.addFormDataPart("tags", targetInstance.defaultTags) + .addFormDataPart("visibility", targetInstance.defaultVisibility) val request = Request.Builder() - .url(prefs.apiUrl) - .addHeader("X-Api-Key", prefs.apiKey) + .url(targetInstance.apiUrl) + .addHeader("X-Api-Key", targetInstance.apiKey) .addHeader("User-Agent", "fuggloader-android/1.0") .post(multipartBuilder.build()) .build() @@ -101,19 +102,20 @@ class UploaderService(private val prefs: PreferencesManager) { suspend fun uploadUrl( url: String, - rating: String = prefs.defaultRating, - tags: String = prefs.defaultTags, - visibility: String = prefs.defaultVisibility + instance: Instance? = null ): Result = withContext(Dispatchers.IO) { - Log.d("UploaderService", "Starting uploadUrl for $url") + val targetInstance = instance ?: prefs.getActiveInstance() + ?: return@withContext Result.failure(Exception("No instance configured")) + + Log.d("UploaderService", "Starting uploadUrl for $url to ${targetInstance.apiUrl}") try { val json = JSONObject().apply { put("url", url) - if (rating != "none") { - put("rating", rating) + if (targetInstance.defaultRating != "none") { + put("rating", targetInstance.defaultRating) } - put("tags", tags) - put("visibility", visibility) + put("tags", targetInstance.defaultTags) + put("visibility", targetInstance.defaultVisibility) } val requestBody = RequestBody.create( @@ -122,8 +124,8 @@ class UploaderService(private val prefs: PreferencesManager) { ) val request = Request.Builder() - .url(prefs.apiUrl) - .addHeader("X-Api-Key", prefs.apiKey) + .url(targetInstance.apiUrl) + .addHeader("X-Api-Key", targetInstance.apiKey) .addHeader("User-Agent", "fuggloader-android/1.0") .post(requestBody) .build() @@ -143,12 +145,15 @@ class UploaderService(private val prefs: PreferencesManager) { } } - suspend fun testConnection(): Result = withContext(Dispatchers.IO) { - Log.d("UploaderService", "Starting testConnection ping") + suspend fun testConnection(instance: Instance? = null): Result = withContext(Dispatchers.IO) { + val targetInstance = instance ?: prefs.getActiveInstance() + ?: return@withContext Result.failure(Exception("No instance configured")) + + Log.d("UploaderService", "Starting testConnection ping for ${targetInstance.apiUrl}") try { val request = Request.Builder() - .url(prefs.apiUrl) - .addHeader("X-Api-Key", prefs.apiKey) + .url(targetInstance.apiUrl) + .addHeader("X-Api-Key", targetInstance.apiKey) .addHeader("User-Agent", "fuggloader-android/1.0") .post(ByteArray(0).toRequestBody(null)) .build() diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 22f9fb5..b89496c 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -28,11 +28,76 @@ android:textColor="#A0A0B0" android:textSize="14sp" /> + + + + + + + + + + + + + + + + + diff --git a/browser-extension/README.md b/browser-extension/README.md index 56f8e7a..f4c11b8 100644 --- a/browser-extension/README.md +++ b/browser-extension/README.md @@ -25,19 +25,67 @@ A ShareX-style browser extension for **Chrome, Brave, Edge, Opera, and Firefox** ## Installation Instructions -### Chrome, Brave, Edge, Opera (Chromium) +### Chrome, Brave, Edge, Opera (Chromium) — Permanent by default 1. Open your browser and navigate to `chrome://extensions` (or `edge://extensions` in Edge). -2. Enable **Developer mode** (toggle in the top-right corner). -3. Click **Load unpacked** in the top-left menu. +2. Enable **Developer mode** (toggle switch in top-right). +3. Click **Load unpacked**. 4. Select the `browser-extension` folder in this repository (`/home/kibi/Projects/f0ckm-uploader/browser-extension`). -5. Pin the **f0ckm Uploader** icon to your toolbar for easy access! +5. *Note*: In Chromium browsers, loading unpacked extensions **remains installed permanently** across browser restarts as long as the folder location is not moved or deleted. + +--- ### Mozilla Firefox -1. Open Firefox and navigate to `about:debugging#/runtime/this-firefox`. +Firefox restricts unsigned extensions from being permanently installed in standard release builds by default. Choose one of the following methods for permanent installation: + +#### Method 1: System Policy (`policies.json`) — Recommended for standard Firefox on Linux + +1. Zip the extension directory: + ```bash + cd /home/kibi/Projects/f0ckm-uploader/browser-extension + zip -r /tmp/f0ckm-uploader.xpi * + ``` +2. Create or edit `/etc/firefox/policies/policies.json` with superuser privileges: + ```json + { + "policies": { + "ExtensionSettings": { + "f0ckm-uploader@local": { + "installation_mode": "force_installed", + "install_url": "file:///tmp/f0ckm-uploader.xpi" + } + } + } + } + ``` +3. Restart Firefox. The extension will be permanently installed. + +#### Method 2: Firefox Developer Edition / Nightly / ESR / LibreWolf / Waterfox + +1. Open `about:config` in the address bar and accept the risk. +2. Search for `xpinstall.signatures.required` and toggle it to **`false`**. +3. Package the extension as `.xpi`: + ```bash + cd /home/kibi/Projects/f0ckm-uploader/browser-extension + zip -r ~/f0ckm-uploader.xpi * + ``` +4. Open `about:addons`, click the gear icon (⚙️) -> **Install Add-on From File...** and select `f0ckm-uploader.xpi`. + +#### Method 3: Self-Signing via Mozilla Add-ons Hub (AMO) — Works on standard Firefox release + +1. Zip the `browser-extension` directory contents. +2. Go to the [Mozilla Add-on Developer Hub](https://addons.mozilla.org/en-US/developers/). +3. Submit a new add-on and choose **"On your own"** (Self-distribution / unlisted). +4. Mozilla's automated scanner will sign your `.xpi` file within a minute. +5. Download your signed `.xpi` file and open it in Firefox (`Ctrl+O` or drag and drop into `about:addons`) to install permanently. + +#### Method 4: Temporary Installation (Development mode) + +1. Navigate to `about:debugging#/runtime/this-firefox`. 2. Click **Load Temporary Add-on...** 3. Select `manifest.json` from the `browser-extension` folder. +*(Note: Temporary add-ons automatically unload when Firefox closes).* --- diff --git a/browser-extension/background.js b/browser-extension/background.js index 6bb18b2..ae5faa0 100644 --- a/browser-extension/background.js +++ b/browser-extension/background.js @@ -122,6 +122,73 @@ chrome.commands.onCommand.addListener(async (command) => { } }); +function normalizeApiUrl(url) { + if (!url) return ""; + url = url.trim().replace(/^['"]|['"]$/g, ""); + if (!url) return ""; + if (!/^https?:\/\//i.test(url)) { + url = "http://" + url; + } + try { + const parsed = new URL(url); + let path = parsed.pathname.replace(/\/+$/, ""); + if (!path || path === "") { + return `${parsed.origin}/api/v2/upload`; + } else if (path.endsWith("/api/v2")) { + return `${parsed.origin}/upload`; + } else if (!path.endsWith("/upload")) { + return `${parsed.origin}/api/v2/upload`; + } + return url; + } catch (e) { + return url; + } +} + +async function copyToClipboard(text) { + if (!text) return; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + } + } catch (e) {} +} + +async function testConnection(apiUrl, apiKey) { + if (!apiUrl || !apiKey) { + return { success: false, msg: "API URL and API Key are required." }; + } + const normUrl = normalizeApiUrl(apiUrl); + try { + const res = await fetch(normUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Api-Key": apiKey, + "Authorization": `Bearer ${apiKey}` + }, + credentials: "omit", + body: JSON.stringify({ api_key: apiKey, key: apiKey }) + }); + if (res.status === 200) { + return { success: true, msg: "Connection successful! (HTTP 200)" }; + } else if (res.status === 401) { + return { success: false, msg: "Unauthorized: Invalid API Key." }; + } else if (res.status === 400 || res.status === 422) { + return { success: true, msg: "Connection successful! (API key verified)" }; + } else { + let text = `HTTP ${res.status}`; + try { + const json = await res.json(); + if (json.msg || json.error || json.message) text = json.msg || json.error || json.message; + } catch (e) {} + return { success: false, msg: text }; + } + } catch (err) { + return { success: false, msg: err.message || "Connection failed. Check server URL and network." }; + } +} + async function processUpload(targetUrl) { if (!targetUrl) return; @@ -160,45 +227,69 @@ async function processUpload(targetUrl) { try { if (!settings.apiUrl || !settings.apiKey) { showNotification("Upload Error", "API URL and API Key must be configured in extension options.", "error"); - return; + return null; } + const normalizedUrl = normalizeApiUrl(settings.apiUrl); + const payload = { url: targetUrl, tags: settings.tags || "url,upload", - visibility: settings.visibility || "0" + visibility: settings.visibility || "0", + api_key: settings.apiKey, + key: settings.apiKey }; - if (settings.rating && settings.rating !== "default") { + if (settings.rating && settings.rating !== "default" && settings.rating !== "") { const ratingMap = { s: "sfw", q: "nsfw", e: "nsfl", sfw: "sfw", nsfw: "nsfw", nsfl: "nsfl" }; payload.rating = ratingMap[settings.rating.toLowerCase()] || settings.rating; } if (settings.isOc) payload.is_oc = "1"; - const uploadRes = await fetch(settings.apiUrl, { + const uploadRes = await fetch(normalizedUrl, { method: "POST", headers: { "Content-Type": "application/json", - "X-Api-Key": settings.apiKey + "X-Api-Key": settings.apiKey, + "Authorization": `Bearer ${settings.apiKey}` }, + credentials: "omit", body: JSON.stringify(payload) }); + if (!uploadRes.ok) { + let errorMsg = `HTTP Error ${uploadRes.status}`; + try { + const errJson = await uploadRes.json(); + if (errJson.msg || errJson.error || errJson.message) { + errorMsg = errJson.msg || errJson.error || errJson.message; + } + } catch (e) {} + showNotification("Upload Failed", errorMsg, "error"); + return null; + } + const json = await uploadRes.json(); - if (json.success && (json.url || json.file_url || json.direct_url || json.target_url || json.file)) { - const postUrl = json.url || json.post_url || ""; - const directUrl = json.file_url || json.direct_url || json.target_url || json.file || ""; - const resultUrl = (settings.urlType === "direct" && directUrl) ? directUrl : (postUrl || directUrl); + const postUrl = json.url || json.post_url || json.link || (json.data && (json.data.url || json.data.post_url)) || ""; + const directUrl = json.file_url || json.direct_url || json.target_url || json.file || (json.data && (json.data.file_url || json.data.direct_url)) || ""; + const resultUrl = (settings.urlType === "direct" && directUrl) ? directUrl : (postUrl || directUrl); - showNotification("Upload Successful!", `Link: ${resultUrl}`, "success"); + if (resultUrl) { + await copyToClipboard(resultUrl); + showNotification("Upload Successful!", `Link copied to clipboard: ${resultUrl}`, "success"); return resultUrl; + } else if (json.success) { + showNotification("Upload Successful!", "Upload processed successfully.", "success"); + return "success"; } else { - const msg = json.msg || "Upload failed"; + const msg = json.msg || json.error || "Upload failed"; showNotification("Upload Failed", msg, "error"); + return null; } } catch (err) { - showNotification("Upload Error", err.message || "An error occurred", "error"); + showNotification("Upload Error", err.message || "An error occurred during network request", "error"); + return null; } } } @@ -238,5 +329,10 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { sendResponse({ status: "ok", resultUrl: resUrl || "" }); }); return true; + } else if (request.action === "test_connection") { + testConnection(request.apiUrl, request.apiKey).then((res) => { + sendResponse(res); + }); + return true; } }); diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json index 4356e70..6ec5935 100644 --- a/browser-extension/manifest.json +++ b/browser-extension/manifest.json @@ -2,6 +2,12 @@ "manifest_version": 3, "name": "f0ckm Uploader", "version": "2.0", + "browser_specific_settings": { + "gecko": { + "id": "f0ckm-uploader@local", + "strict_min_version": "109.0" + } + }, "description": "Upload images, media, links, and URLs directly to f0ckm from context menu or toolbar.", "permissions": [ "contextMenus", diff --git a/browser-extension/options/options.html b/browser-extension/options/options.html index db19eac..e48f36a 100644 --- a/browser-extension/options/options.html +++ b/browser-extension/options/options.html @@ -46,6 +46,10 @@ +
+ + +
diff --git a/browser-extension/options/options.js b/browser-extension/options/options.js index 5cbba90..617a0c5 100644 --- a/browser-extension/options/options.js +++ b/browser-extension/options/options.js @@ -9,6 +9,29 @@ const DEFAULT_SETTINGS = { urlType: "post" }; +function normalizeApiUrl(url) { + if (!url) return ""; + url = url.trim().replace(/^['"]|['"]$/g, ""); + if (!url) return ""; + if (!/^https?:\/\//i.test(url)) { + url = "http://" + url; + } + try { + const parsed = new URL(url); + let path = parsed.pathname.replace(/\/+$/, ""); + if (!path || path === "") { + return `${parsed.origin}/api/v2/upload`; + } else if (path.endsWith("/api/v2")) { + return `${parsed.origin}/upload`; + } else if (!path.endsWith("/upload")) { + return `${parsed.origin}/api/v2/upload`; + } + return url; + } catch (e) { + return url; + } +} + document.addEventListener("DOMContentLoaded", () => { const form = document.getElementById("settings-form"); const modeDesktop = document.getElementById("mode-desktop"); @@ -21,6 +44,8 @@ document.addEventListener("DOMContentLoaded", () => { const isOc = document.getElementById("isOc"); const urlType = document.getElementById("urlType"); const saveToast = document.getElementById("save-toast"); + const btnTest = document.getElementById("btn-test-connection"); + const testToast = document.getElementById("test-toast"); // Load existing settings chrome.storage.sync.get(DEFAULT_SETTINGS, (items) => { @@ -42,9 +67,15 @@ document.addEventListener("DOMContentLoaded", () => { // Save settings on form submit form.addEventListener("submit", (e) => { e.preventDefault(); + const rawUrl = apiUrl.value.trim(); + const normUrl = normalizeApiUrl(rawUrl); + if (normUrl && normUrl !== rawUrl) { + apiUrl.value = normUrl; + } + const newSettings = { uploadMode: modeDirect.checked ? "direct" : "desktop", - apiUrl: apiUrl.value.trim(), + apiUrl: normUrl || rawUrl, apiKey: apiKey.value.trim(), rating: rating.value, visibility: visibility.value, @@ -60,4 +91,39 @@ document.addEventListener("DOMContentLoaded", () => { }, 2500); }); }); + + // Test Connection button + if (btnTest) { + btnTest.addEventListener("click", () => { + const url = apiUrl.value.trim(); + const key = apiKey.value.trim(); + if (!url || !key) { + if (testToast) { + testToast.textContent = "Please enter API URL and API Key."; + testToast.style.color = "#ff4444"; + testToast.classList.remove("hidden"); + } + return; + } + + btnTest.disabled = true; + btnTest.textContent = "Testing..."; + if (testToast) testToast.classList.add("hidden"); + + chrome.runtime.sendMessage({ action: "test_connection", apiUrl: url, apiKey: key }, (res) => { + btnTest.disabled = false; + btnTest.textContent = "Test Connection"; + if (testToast) { + testToast.classList.remove("hidden"); + if (res && res.success) { + testToast.textContent = "✓ " + res.msg; + testToast.style.color = "#00e676"; + } else { + testToast.textContent = "❌ " + (res ? res.msg : "Test failed"); + testToast.style.color = "#ff4444"; + } + } + }); + }); + } }); diff --git a/browser-extension/popup/popup.css b/browser-extension/popup/popup.css index 5841ca3..22edbc8 100644 --- a/browser-extension/popup/popup.css +++ b/browser-extension/popup/popup.css @@ -20,12 +20,79 @@ html, body { .header { display: flex; align-items: center; - justify-content: center; + justify-content: space-between; padding: 12px 14px; background: #191b22; border-bottom: 1px solid #282a36; } +.icon-btn { + background: transparent; + border: none; + font-size: 15px; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: background 0.15s ease; +} + +.icon-btn:hover { + background: #282c3f; +} + +.api-config-card { + background: #191b26; + border: 1px solid #0066ff44; + border-radius: 8px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.api-config-card.hidden { + display: none; +} + +.config-header { + font-size: 11px; + font-weight: 700; + color: #4da6ff; + margin-bottom: 2px; +} + +.api-config-card .field { + display: flex; + flex-direction: column; + gap: 2px; +} + +.api-config-card label { + font-size: 10px; + color: #a0a6b8; +} + +.api-config-card input { + background: #12131a; + border: 1px solid #282c3f; + color: #ffffff; + padding: 6px 8px; + border-radius: 5px; + font-size: 11px; +} + +.config-actions { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; +} + +.popup-toast { + font-size: 10px; + margin-left: 4px; +} + .brand { display: flex; align-items: center; diff --git a/browser-extension/popup/popup.html b/browser-extension/popup/popup.html index 8ec1055..ca6a63a 100644 --- a/browser-extension/popup/popup.html +++ b/browser-extension/popup/popup.html @@ -12,6 +12,7 @@ f0ckm Logo f0ckm Uploader
+
@@ -26,6 +27,24 @@
+
🌐 Web Page diff --git a/browser-extension/popup/popup.js b/browser-extension/popup/popup.js index 83e350a..76d26cd 100644 --- a/browser-extension/popup/popup.js +++ b/browser-extension/popup/popup.js @@ -1,6 +1,37 @@ +function normalizeApiUrl(url) { + if (!url) return ""; + url = url.trim().replace(/^['"]|['"]$/g, ""); + if (!url) return ""; + if (!/^https?:\/\//i.test(url)) { + url = "http://" + url; + } + try { + const parsed = new URL(url); + let path = parsed.pathname.replace(/\/+$/, ""); + if (!path || path === "") { + return `${parsed.origin}/api/v2/upload`; + } else if (path.endsWith("/api/v2")) { + return `${parsed.origin}/upload`; + } else if (!path.endsWith("/upload")) { + return `${parsed.origin}/api/v2/upload`; + } + return url; + } catch (e) { + return url; + } +} + function initPopup() { const modeDesktop = document.getElementById("mode-desktop"); const modeDirect = document.getElementById("mode-direct"); + const btnOpenOptions = document.getElementById("btn-open-options"); + const apiConfigCard = document.getElementById("api-config-card"); + const popupApiUrl = document.getElementById("popup-api-url"); + const popupApiKey = document.getElementById("popup-api-key"); + const btnSaveApiConfig = document.getElementById("btn-save-api-config"); + const btnPopupTest = document.getElementById("btn-popup-test"); + const popupApiToast = document.getElementById("popup-api-toast"); + const btnUploadTab = document.getElementById("btn-upload-tab"); const btnUploadText = document.getElementById("btn-upload-text"); const btnUploadUrl = document.getElementById("btn-upload-url"); @@ -17,26 +48,115 @@ function initPopup() { let activeTabUrl = ""; - // 1. Load settings asynchronously without blocking UI initialization - try { - chrome.storage.sync.get({ uploadMode: "desktop" }, (settings) => { - if (chrome.runtime.lastError || !settings) return; - if (settings.uploadMode === "direct") { - if (modeDirect) modeDirect.checked = true; + if (btnOpenOptions) { + btnOpenOptions.addEventListener("click", () => { + if (chrome.runtime.openOptionsPage) { + chrome.runtime.openOptionsPage(); } else { - if (modeDesktop) modeDesktop.checked = true; + window.open(chrome.runtime.getURL("options/options.html")); } }); + } + + function updateModeUI(mode) { + if (mode === "direct") { + if (modeDirect) modeDirect.checked = true; + if (apiConfigCard) apiConfigCard.classList.remove("hidden"); + } else { + if (modeDesktop) modeDesktop.checked = true; + if (apiConfigCard) apiConfigCard.classList.add("hidden"); + } + } + + // 1. Load settings & drafts asynchronously without blocking UI initialization + try { + chrome.storage.sync.get({ uploadMode: "desktop", apiUrl: "", apiKey: "", draftUrl: "" }, (settings) => { + if (chrome.runtime.lastError || !settings) return; + if (popupApiUrl) popupApiUrl.value = settings.apiUrl || ""; + if (popupApiKey) popupApiKey.value = settings.apiKey || ""; + if (urlInput && settings.draftUrl) urlInput.value = settings.draftUrl; + updateModeUI(settings.uploadMode); + }); } catch (e) {} + // Auto-save on input so values are never lost if popup closes when copying/switching tabs + if (popupApiUrl) { + popupApiUrl.addEventListener("input", () => { + chrome.storage.sync.set({ apiUrl: popupApiUrl.value }); + }); + } + if (popupApiKey) { + popupApiKey.addEventListener("input", () => { + chrome.storage.sync.set({ apiKey: popupApiKey.value }); + }); + } + if (urlInput) { + urlInput.addEventListener("input", () => { + chrome.storage.sync.set({ draftUrl: urlInput.value }); + }); + } + if (modeDesktop) { modeDesktop.addEventListener("change", () => { - chrome.storage.sync.set({ uploadMode: "desktop" }); + chrome.storage.sync.set({ uploadMode: "desktop" }, () => { + updateModeUI("desktop"); + }); }); } if (modeDirect) { modeDirect.addEventListener("change", () => { - chrome.storage.sync.set({ uploadMode: "direct" }); + chrome.storage.sync.set({ uploadMode: "direct" }, () => { + updateModeUI("direct"); + }); + }); + } + + if (btnSaveApiConfig) { + btnSaveApiConfig.addEventListener("click", () => { + const rawUrl = popupApiUrl ? popupApiUrl.value.trim() : ""; + const key = popupApiKey ? popupApiKey.value.trim() : ""; + const normUrl = normalizeApiUrl(rawUrl); + if (popupApiUrl && normUrl) popupApiUrl.value = normUrl; + + chrome.storage.sync.set({ apiUrl: normUrl || rawUrl, apiKey: key }, () => { + if (popupApiToast) { + popupApiToast.textContent = "Saved!"; + popupApiToast.style.color = "#00e676"; + setTimeout(() => { popupApiToast.textContent = ""; }, 2000); + } + }); + }); + } + + if (btnPopupTest) { + btnPopupTest.addEventListener("click", () => { + const url = popupApiUrl ? popupApiUrl.value.trim() : ""; + const key = popupApiKey ? popupApiKey.value.trim() : ""; + if (!url || !key) { + if (popupApiToast) { + popupApiToast.textContent = "Enter URL & Key!"; + popupApiToast.style.color = "#ff4444"; + } + return; + } + btnPopupTest.disabled = true; + if (popupApiToast) { + popupApiToast.textContent = "Testing..."; + popupApiToast.style.color = "#4da6ff"; + } + + chrome.runtime.sendMessage({ action: "test_connection", apiUrl: url, apiKey: key }, (res) => { + btnPopupTest.disabled = false; + if (popupApiToast) { + if (res && res.success) { + popupApiToast.textContent = "✓ OK!"; + popupApiToast.style.color = "#00e676"; + } else { + popupApiToast.textContent = "❌ " + (res ? res.msg : "Failed"); + popupApiToast.style.color = "#ff4444"; + } + } + }); }); } @@ -170,7 +290,7 @@ function initPopup() { if (response && response.resultUrl) { showSuccessResult(response.resultUrl); } else { - showStatus("URL Upload queued! Link copied to clipboard.", false); + showStatus("Upload failed or queued. Check notification / settings.", false); } }); }